fiber_audit 0.2.1 → 0.3.1
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/.fiber-audit.example.yml +7 -8
- data/ARCHITECTURE.md +97 -673
- data/CHANGELOG.md +52 -0
- data/README.md +163 -86
- data/lib/fiber_audit/cli.rb +19 -5
- data/lib/fiber_audit/configuration.rb +31 -6
- data/lib/fiber_audit/operation_semantics.rb +172 -0
- data/lib/fiber_audit/operation_vocabulary.rb +2 -1
- data/lib/fiber_audit/runtime/active_operations.rb +39 -9
- data/lib/fiber_audit/runtime/boot.rb +4 -0
- data/lib/fiber_audit/runtime/environment.rb +58 -0
- data/lib/fiber_audit/runtime/execution_context.rb +55 -43
- data/lib/fiber_audit/runtime/lifecycle.rb +91 -50
- data/lib/fiber_audit/runtime/operation_liveness_monitor.rb +282 -0
- data/lib/fiber_audit/runtime/operation_liveness_policy.rb +47 -0
- data/lib/fiber_audit/runtime/probes/base.rb +27 -4
- data/lib/fiber_audit/runtime/probes/subprocess.rb +89 -8
- data/lib/fiber_audit/runtime/probes/thread_state.rb +0 -14
- data/lib/fiber_audit/runtime/scheduler_evidence_classifier.rb +60 -0
- data/lib/fiber_audit/runtime/scheduler_observer.rb +54 -18
- data/lib/fiber_audit/runtime/scheduler_snapshot.rb +95 -0
- data/lib/fiber_audit/runtime/watchdog.rb +54 -4
- data/lib/fiber_audit/runtime.rb +5 -0
- data/lib/fiber_audit/static/call_site_extractor.rb +1 -0
- data/lib/fiber_audit/static/rules/base.rb +19 -0
- data/lib/fiber_audit/static/rules/blocking_subprocess.rb +67 -10
- data/lib/fiber_audit/static/rules/direct_socket.rb +54 -19
- data/lib/fiber_audit/static/rules/io_select.rb +6 -6
- data/lib/fiber_audit/static/rules/net_http_in_request.rb +11 -8
- data/lib/fiber_audit/static/rules/synchronization.rb +13 -8
- data/lib/fiber_audit/static/rules/thread_current_state.rb +20 -30
- data/lib/fiber_audit/static/rules/thread_join.rb +9 -7
- data/lib/fiber_audit/version.rb +1 -1
- metadata +6 -1
|
@@ -14,6 +14,8 @@ require_relative 'scheduler_observer'
|
|
|
14
14
|
require_relative 'session'
|
|
15
15
|
require_relative 'watchdog'
|
|
16
16
|
require_relative 'watchdog_policy'
|
|
17
|
+
require_relative 'operation_liveness_monitor'
|
|
18
|
+
require_relative 'operation_liveness_policy'
|
|
17
19
|
|
|
18
20
|
module FiberAudit
|
|
19
21
|
module Runtime
|
|
@@ -21,6 +23,7 @@ module FiberAudit
|
|
|
21
23
|
class Lifecycle
|
|
22
24
|
attr_reader :settings, :owner_pid, :output_path, :recorder,
|
|
23
25
|
:watchdog, :active_operations, :watchdog_policy,
|
|
26
|
+
:operation_liveness_monitor, :operation_liveness_policy,
|
|
24
27
|
:redactor, :probe_registry, :execution_context_store,
|
|
25
28
|
:rails_integration
|
|
26
29
|
|
|
@@ -31,6 +34,7 @@ module FiberAudit
|
|
|
31
34
|
def initialize(
|
|
32
35
|
settings:,
|
|
33
36
|
watchdog_policy: nil,
|
|
37
|
+
operation_liveness_policy: nil,
|
|
34
38
|
probes_enabled: false,
|
|
35
39
|
clock: Clock.new,
|
|
36
40
|
session_id_source: SecureRandom.method(:uuid),
|
|
@@ -39,11 +43,12 @@ module FiberAudit
|
|
|
39
43
|
random: Sampler::RANDOM_SOURCE
|
|
40
44
|
)
|
|
41
45
|
validate_dependencies!(
|
|
42
|
-
settings, watchdog_policy, probes_enabled, clock,
|
|
46
|
+
settings, watchdog_policy, operation_liveness_policy, probes_enabled, clock,
|
|
43
47
|
session_id_source, pid_source, writer_factory, random
|
|
44
48
|
)
|
|
45
49
|
@settings = settings
|
|
46
50
|
@watchdog_policy = watchdog_policy
|
|
51
|
+
@operation_liveness_policy = operation_liveness_policy
|
|
47
52
|
@probes_enabled = probes_enabled
|
|
48
53
|
@clock = clock
|
|
49
54
|
@session_id_source = session_id_source
|
|
@@ -52,15 +57,7 @@ module FiberAudit
|
|
|
52
57
|
@random = random
|
|
53
58
|
@owner_pid = current_pid
|
|
54
59
|
@state = :starting
|
|
55
|
-
|
|
56
|
-
@recorder = nil
|
|
57
|
-
@watchdog = nil
|
|
58
|
-
@scheduler_observer = nil
|
|
59
|
-
@active_operations = nil
|
|
60
|
-
@redactor = nil
|
|
61
|
-
@probe_registry = nil
|
|
62
|
-
@execution_context_store = nil
|
|
63
|
-
@rails_integration = nil
|
|
60
|
+
reset_process_components!
|
|
64
61
|
start_process_session!
|
|
65
62
|
rescue StandardError => e
|
|
66
63
|
startup_failure!(e)
|
|
@@ -84,15 +81,7 @@ module FiberAudit
|
|
|
84
81
|
|
|
85
82
|
abandon_inherited_runtime!
|
|
86
83
|
@owner_pid = pid
|
|
87
|
-
|
|
88
|
-
@recorder = nil
|
|
89
|
-
@watchdog = nil
|
|
90
|
-
@scheduler_observer = nil
|
|
91
|
-
@active_operations = nil
|
|
92
|
-
@redactor = nil
|
|
93
|
-
@probe_registry = nil
|
|
94
|
-
@execution_context_store = nil
|
|
95
|
-
@rails_integration = nil
|
|
84
|
+
reset_process_components!
|
|
96
85
|
@state = :starting
|
|
97
86
|
# Reset fiber-local context after fork
|
|
98
87
|
ExecutionContext.reset!
|
|
@@ -122,13 +111,18 @@ module FiberAudit
|
|
|
122
111
|
|
|
123
112
|
private
|
|
124
113
|
|
|
125
|
-
def validate_dependencies!(candidate_settings, watchdog, probes, candidate_clock,
|
|
114
|
+
def validate_dependencies!(candidate_settings, watchdog, liveness, probes, candidate_clock,
|
|
115
|
+
session_ids, pids, writers, random)
|
|
126
116
|
unless candidate_settings.is_a?(Environment::Settings)
|
|
127
117
|
raise RuntimeContractError, 'settings must be FiberAudit::Runtime::Environment::Settings'
|
|
128
118
|
end
|
|
129
119
|
unless watchdog.nil? || watchdog.is_a?(WatchdogPolicy)
|
|
130
120
|
raise RuntimeContractError, 'watchdog_policy must be a FiberAudit::Runtime::WatchdogPolicy or nil'
|
|
131
121
|
end
|
|
122
|
+
unless liveness.nil? || liveness.is_a?(OperationLivenessPolicy)
|
|
123
|
+
raise RuntimeContractError,
|
|
124
|
+
'operation_liveness_policy must be a FiberAudit::Runtime::OperationLivenessPolicy or nil'
|
|
125
|
+
end
|
|
132
126
|
raise RuntimeContractError, 'probes_enabled must be a Boolean' unless [true, false].include?(probes)
|
|
133
127
|
raise RuntimeContractError, 'clock must be a FiberAudit::Runtime::Clock' unless candidate_clock.is_a?(Clock)
|
|
134
128
|
|
|
@@ -142,6 +136,19 @@ module FiberAudit
|
|
|
142
136
|
end
|
|
143
137
|
end
|
|
144
138
|
|
|
139
|
+
def reset_process_components!
|
|
140
|
+
@output_path = nil
|
|
141
|
+
@recorder = nil
|
|
142
|
+
@watchdog = nil
|
|
143
|
+
@operation_liveness_monitor = nil
|
|
144
|
+
@scheduler_observer = nil
|
|
145
|
+
@active_operations = nil
|
|
146
|
+
@redactor = nil
|
|
147
|
+
@probe_registry = nil
|
|
148
|
+
@execution_context_store = nil
|
|
149
|
+
@rails_integration = nil
|
|
150
|
+
end
|
|
151
|
+
|
|
145
152
|
def start_process_session!
|
|
146
153
|
session_id = @session_id_source.call
|
|
147
154
|
started_at = @clock.wall_time
|
|
@@ -175,6 +182,7 @@ module FiberAudit
|
|
|
175
182
|
@redactor = Redactor.new(root: settings.project_root, policy: settings.policy)
|
|
176
183
|
@execution_context_store = ExecutionContext if @probes_enabled
|
|
177
184
|
setup_watchdog! if watchdog_policy
|
|
185
|
+
setup_operation_liveness! if @probes_enabled && operation_liveness_policy
|
|
178
186
|
setup_rails_integration! if @probes_enabled
|
|
179
187
|
setup_probes! if @probes_enabled
|
|
180
188
|
rescue StandardError => e
|
|
@@ -202,6 +210,13 @@ module FiberAudit
|
|
|
202
210
|
ensure
|
|
203
211
|
@rails_integration = nil
|
|
204
212
|
end
|
|
213
|
+
begin
|
|
214
|
+
@operation_liveness_monitor&.stop
|
|
215
|
+
rescue StandardError
|
|
216
|
+
nil
|
|
217
|
+
ensure
|
|
218
|
+
@operation_liveness_monitor = nil
|
|
219
|
+
end
|
|
205
220
|
begin
|
|
206
221
|
if @scheduler_observer
|
|
207
222
|
SchedulerObserver.deactivate(@scheduler_observer)
|
|
@@ -215,6 +230,15 @@ module FiberAudit
|
|
|
215
230
|
end
|
|
216
231
|
end
|
|
217
232
|
|
|
233
|
+
def setup_operation_liveness!
|
|
234
|
+
@operation_liveness_monitor = OperationLivenessMonitor.new(
|
|
235
|
+
policy: operation_liveness_policy,
|
|
236
|
+
recorder: recorder,
|
|
237
|
+
active_operations: active_operations,
|
|
238
|
+
clock: @clock
|
|
239
|
+
)
|
|
240
|
+
end
|
|
241
|
+
|
|
218
242
|
def setup_rails_integration!
|
|
219
243
|
@rails_integration = RailsIntegration.activate(
|
|
220
244
|
context_store: @execution_context_store,
|
|
@@ -272,44 +296,61 @@ module FiberAudit
|
|
|
272
296
|
def abandon_inherited_runtime!
|
|
273
297
|
recorder&.writer&.close
|
|
274
298
|
ensure
|
|
275
|
-
|
|
276
|
-
@watchdog = nil
|
|
277
|
-
@scheduler_observer = nil
|
|
278
|
-
@active_operations = nil
|
|
279
|
-
@redactor = nil
|
|
280
|
-
@probe_registry = nil
|
|
281
|
-
@execution_context_store = nil
|
|
282
|
-
@rails_integration = nil
|
|
299
|
+
reset_process_components!
|
|
283
300
|
end
|
|
284
301
|
|
|
285
302
|
def stop_runtime_observers
|
|
286
303
|
error = nil
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
Probes::Registry.deactivate(probe_registry) if probe_registry
|
|
296
|
-
rescue StandardError => e
|
|
297
|
-
error ||= e
|
|
298
|
-
ensure
|
|
299
|
-
@probe_registry = nil
|
|
300
|
-
end
|
|
301
|
-
begin
|
|
302
|
-
SchedulerObserver.deactivate(@scheduler_observer) if @scheduler_observer
|
|
303
|
-
watchdog&.stop
|
|
304
|
-
rescue StandardError => e
|
|
305
|
-
error ||= e
|
|
306
|
-
ensure
|
|
307
|
-
@scheduler_observer = nil
|
|
308
|
-
end
|
|
304
|
+
component_error = stop_rails_integration
|
|
305
|
+
error ||= component_error
|
|
306
|
+
component_error = stop_probe_registry
|
|
307
|
+
error ||= component_error
|
|
308
|
+
component_error = stop_operation_liveness_monitor
|
|
309
|
+
error ||= component_error
|
|
310
|
+
component_error = stop_watchdog
|
|
311
|
+
error ||= component_error
|
|
309
312
|
recorder&.internal_error! if error
|
|
310
313
|
error
|
|
311
314
|
end
|
|
312
315
|
|
|
316
|
+
def stop_rails_integration
|
|
317
|
+
RailsIntegration.deactivate(@rails_integration) if @rails_integration
|
|
318
|
+
nil
|
|
319
|
+
rescue StandardError => e
|
|
320
|
+
e
|
|
321
|
+
ensure
|
|
322
|
+
@rails_integration = nil
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def stop_probe_registry
|
|
326
|
+
Probes::Registry.deactivate(probe_registry) if probe_registry
|
|
327
|
+
nil
|
|
328
|
+
rescue StandardError => e
|
|
329
|
+
e
|
|
330
|
+
ensure
|
|
331
|
+
@probe_registry = nil
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def stop_operation_liveness_monitor
|
|
335
|
+
operation_liveness_monitor&.stop
|
|
336
|
+
nil
|
|
337
|
+
rescue StandardError => e
|
|
338
|
+
e
|
|
339
|
+
ensure
|
|
340
|
+
@operation_liveness_monitor = nil
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def stop_watchdog
|
|
344
|
+
SchedulerObserver.deactivate(@scheduler_observer) if @scheduler_observer
|
|
345
|
+
watchdog&.stop
|
|
346
|
+
nil
|
|
347
|
+
rescue StandardError => e
|
|
348
|
+
e
|
|
349
|
+
ensure
|
|
350
|
+
@scheduler_observer = nil
|
|
351
|
+
@watchdog = nil
|
|
352
|
+
end
|
|
353
|
+
|
|
313
354
|
def startup_failure!(error)
|
|
314
355
|
raise error unless defined?(@settings) && settings.policy.fail_open?
|
|
315
356
|
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'active_operations'
|
|
4
|
+
require_relative 'clock'
|
|
5
|
+
require_relative 'event'
|
|
6
|
+
require_relative 'operation_liveness_policy'
|
|
7
|
+
require_relative 'recorder'
|
|
8
|
+
require_relative 'scheduler_evidence_classifier'
|
|
9
|
+
|
|
10
|
+
module FiberAudit
|
|
11
|
+
module Runtime
|
|
12
|
+
class OperationLivenessMonitor
|
|
13
|
+
SOURCE = :operation_liveness_monitor
|
|
14
|
+
STOP_TIMEOUT_SECONDS = 1
|
|
15
|
+
MAX_START_EVENTS_PER_POLL = 10
|
|
16
|
+
Tracked = Data.define(:long_active_sequence, :entry)
|
|
17
|
+
|
|
18
|
+
attr_reader :policy, :recorder, :active_operations, :state
|
|
19
|
+
|
|
20
|
+
def initialize(policy:, recorder:, active_operations:, clock: Clock.new,
|
|
21
|
+
thread_factory: ->(&block) { Thread.new(&block) })
|
|
22
|
+
validate_dependencies!(policy, recorder, active_operations, clock, thread_factory)
|
|
23
|
+
@policy = policy
|
|
24
|
+
@recorder = recorder
|
|
25
|
+
@active_operations = active_operations
|
|
26
|
+
@clock = clock
|
|
27
|
+
@thread_factory = thread_factory
|
|
28
|
+
@poll_mutex = Mutex.new
|
|
29
|
+
@wait_mutex = Mutex.new
|
|
30
|
+
@condition = ConditionVariable.new
|
|
31
|
+
@tracked = {}
|
|
32
|
+
@long_active_sequence = 0
|
|
33
|
+
@monitor_thread = nil
|
|
34
|
+
@state = policy.enabled? ? :starting : :disabled
|
|
35
|
+
@stopping = false
|
|
36
|
+
@stopped = false
|
|
37
|
+
activate!
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def enabled? = policy.enabled?
|
|
41
|
+
def fail_open? = recorder.session.policy.fail_open?
|
|
42
|
+
|
|
43
|
+
def poll(now_ns: nil)
|
|
44
|
+
return state unless enabled? && state == :active
|
|
45
|
+
|
|
46
|
+
observed_now = now_ns.nil? ? @clock.monotonic_ns : Validation.integer(now_ns, 'operation liveness poll time')
|
|
47
|
+
@poll_mutex.synchronize { poll_operations(observed_now) }
|
|
48
|
+
state
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
handle_failure(e)
|
|
51
|
+
raise e unless fail_open?
|
|
52
|
+
|
|
53
|
+
:unsupported
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def stop
|
|
57
|
+
return self if @stopped
|
|
58
|
+
|
|
59
|
+
@wait_mutex.synchronize do
|
|
60
|
+
@stopping = true
|
|
61
|
+
@condition.broadcast
|
|
62
|
+
end
|
|
63
|
+
stop_monitor_thread
|
|
64
|
+
now_ns = safe_monotonic_ns
|
|
65
|
+
@poll_mutex.synchronize { close_tracked(now_ns, operation_finished: false) }
|
|
66
|
+
@stopped = true
|
|
67
|
+
self
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
handle_failure(e)
|
|
70
|
+
@stopped = true
|
|
71
|
+
raise e unless fail_open?
|
|
72
|
+
|
|
73
|
+
self
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def activate!
|
|
79
|
+
if policy.enabled?
|
|
80
|
+
start_monitor_thread
|
|
81
|
+
@state = :active
|
|
82
|
+
emit_state(:operation_liveness_active)
|
|
83
|
+
else
|
|
84
|
+
emit_state(:operation_liveness_disabled)
|
|
85
|
+
end
|
|
86
|
+
rescue StandardError => e
|
|
87
|
+
handle_failure(e)
|
|
88
|
+
raise e unless fail_open?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def validate_dependencies!(candidate_policy, candidate_recorder, operations, candidate_clock, threads)
|
|
92
|
+
unless candidate_policy.is_a?(OperationLivenessPolicy)
|
|
93
|
+
raise RuntimeContractError, 'policy must be a FiberAudit::Runtime::OperationLivenessPolicy'
|
|
94
|
+
end
|
|
95
|
+
unless candidate_recorder.is_a?(Recorder)
|
|
96
|
+
raise RuntimeContractError,
|
|
97
|
+
'recorder must be a FiberAudit::Runtime::Recorder'
|
|
98
|
+
end
|
|
99
|
+
unless operations.is_a?(ActiveOperations)
|
|
100
|
+
raise RuntimeContractError, 'active_operations must be FiberAudit::Runtime::ActiveOperations'
|
|
101
|
+
end
|
|
102
|
+
raise RuntimeContractError, 'clock must be a FiberAudit::Runtime::Clock' unless candidate_clock.is_a?(Clock)
|
|
103
|
+
raise RuntimeContractError, 'thread_factory must respond to call' unless threads.respond_to?(:call)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def poll_operations(now_ns)
|
|
107
|
+
snapshot = active_operations.snapshot_with_metadata
|
|
108
|
+
current = snapshot.entries.to_h { |entry| [entry.sequence, entry] }
|
|
109
|
+
complete_absent(current, now_ns)
|
|
110
|
+
start_overdue(snapshot, current, now_ns)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def complete_absent(current, now_ns)
|
|
114
|
+
@tracked.keys.reject { |sequence| current.key?(sequence) }.each do |sequence|
|
|
115
|
+
emit_completion(@tracked.delete(sequence), now_ns, operation_finished: true)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def start_overdue(snapshot, current, now_ns)
|
|
120
|
+
overdue = current.values.reject do |entry|
|
|
121
|
+
@tracked.key?(entry.sequence) || !long_active?(entry, now_ns)
|
|
122
|
+
end
|
|
123
|
+
bounded = overdue.first(MAX_START_EVENTS_PER_POLL)
|
|
124
|
+
batch_truncated = overdue.size > bounded.size
|
|
125
|
+
bounded.each do |entry|
|
|
126
|
+
@long_active_sequence += 1
|
|
127
|
+
tracked = Tracked.new(long_active_sequence: @long_active_sequence, entry: entry)
|
|
128
|
+
@tracked[entry.sequence] = tracked
|
|
129
|
+
emit_start(tracked, now_ns, snapshot, batch_truncated, overdue.size)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def long_active?(entry, now_ns)
|
|
134
|
+
raise RuntimeSafetyError, 'operation liveness clock moved backwards' if now_ns < entry.started_monotonic_ns
|
|
135
|
+
|
|
136
|
+
policy.long_active?(age_ns: now_ns - entry.started_monotonic_ns)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def classifier_measurements(entry)
|
|
140
|
+
SchedulerEvidenceClassifier.measurements(
|
|
141
|
+
operation: entry.operation,
|
|
142
|
+
scheduler_snapshot: entry.scheduler_snapshot
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def emit_start(tracked, now_ns, snapshot, batch_truncated, candidate_count)
|
|
147
|
+
entry = tracked.entry
|
|
148
|
+
age_ns = now_ns - entry.started_monotonic_ns
|
|
149
|
+
measurements = {
|
|
150
|
+
long_active_sequence: tracked.long_active_sequence,
|
|
151
|
+
operation_sequence: entry.sequence,
|
|
152
|
+
operation_started_monotonic_ns: entry.started_monotonic_ns,
|
|
153
|
+
observed_age_ns: age_ns,
|
|
154
|
+
long_active_threshold_ns: policy.long_active_threshold_ns,
|
|
155
|
+
poll_interval_ns: policy.poll_interval_ns,
|
|
156
|
+
active_operation_total_count: snapshot.total_count,
|
|
157
|
+
active_operation_snapshot_truncated: snapshot.truncated?,
|
|
158
|
+
long_active_batch_truncated: batch_truncated,
|
|
159
|
+
long_active_candidate_count: candidate_count
|
|
160
|
+
}
|
|
161
|
+
measurements.merge!(entry.scheduler_snapshot.to_measurements) if entry.scheduler_snapshot
|
|
162
|
+
measurements.merge!(classifier_measurements(entry))
|
|
163
|
+
emit_event(kind: :operation_long_active_started, entry: entry, monotonic_ns: now_ns,
|
|
164
|
+
duration_ns: age_ns, measurements: measurements)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def emit_completion(tracked, now_ns, operation_finished:)
|
|
168
|
+
entry = tracked.entry
|
|
169
|
+
duration_ns = [now_ns - entry.started_monotonic_ns, 0].max
|
|
170
|
+
measurements = {
|
|
171
|
+
long_active_sequence: tracked.long_active_sequence,
|
|
172
|
+
operation_sequence: entry.sequence,
|
|
173
|
+
operation_started_monotonic_ns: entry.started_monotonic_ns,
|
|
174
|
+
long_active_threshold_ns: policy.long_active_threshold_ns,
|
|
175
|
+
operation_finished: operation_finished
|
|
176
|
+
}
|
|
177
|
+
measurements.merge!(entry.scheduler_snapshot.to_measurements) if entry.scheduler_snapshot
|
|
178
|
+
measurements.merge!(classifier_measurements(entry))
|
|
179
|
+
emit_event(kind: :operation_long_active_completed, entry: entry, monotonic_ns: now_ns,
|
|
180
|
+
duration_ns: duration_ns, measurements: measurements)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def close_tracked(now_ns, operation_finished:)
|
|
184
|
+
tracked = @tracked.values
|
|
185
|
+
@tracked.clear
|
|
186
|
+
tracked.each { |entry| emit_completion(entry, now_ns, operation_finished: operation_finished) }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def emit_state(kind)
|
|
190
|
+
emit_event(kind: kind, entry: nil, monotonic_ns: @clock.monotonic_ns,
|
|
191
|
+
measurements: { poll_interval_ns: policy.poll_interval_ns,
|
|
192
|
+
long_active_threshold_ns: policy.long_active_threshold_ns })
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def emit_event(kind:, entry:, monotonic_ns:, duration_ns: nil, measurements: {})
|
|
196
|
+
recorder.record_control do
|
|
197
|
+
Event.new(
|
|
198
|
+
kind: kind,
|
|
199
|
+
source: SOURCE,
|
|
200
|
+
occurred_at: @clock.wall_time,
|
|
201
|
+
monotonic_ns: monotonic_ns,
|
|
202
|
+
duration_ns: duration_ns,
|
|
203
|
+
operation: entry&.operation,
|
|
204
|
+
location: entry&.location,
|
|
205
|
+
execution_context: entry&.execution_context || :unknown,
|
|
206
|
+
thread_id: entry&.thread_id,
|
|
207
|
+
fiber_id: entry&.fiber_id,
|
|
208
|
+
measurements: measurements
|
|
209
|
+
)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def start_monitor_thread
|
|
214
|
+
thread = @thread_factory.call { monitor_loop }
|
|
215
|
+
raise RuntimeContractError, 'thread_factory must return a Thread' unless thread.is_a?(Thread)
|
|
216
|
+
|
|
217
|
+
@monitor_thread = thread
|
|
218
|
+
thread.report_on_exception = false
|
|
219
|
+
thread.abort_on_exception = !fail_open?
|
|
220
|
+
thread.name = 'fiber-audit-operation-liveness' if thread.respond_to?(:name=)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def monitor_loop
|
|
224
|
+
loop do
|
|
225
|
+
should_stop = @wait_mutex.synchronize do
|
|
226
|
+
@condition.wait(@wait_mutex, policy.poll_interval_ms.fdiv(1_000)) unless @stopping
|
|
227
|
+
@stopping
|
|
228
|
+
end
|
|
229
|
+
break if should_stop
|
|
230
|
+
|
|
231
|
+
poll
|
|
232
|
+
end
|
|
233
|
+
rescue StandardError => e
|
|
234
|
+
handle_failure(e)
|
|
235
|
+
raise e unless fail_open?
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def stop_monitor_thread
|
|
239
|
+
thread = @monitor_thread
|
|
240
|
+
return unless thread && thread != Thread.current
|
|
241
|
+
return if thread.join(STOP_TIMEOUT_SECONDS)
|
|
242
|
+
|
|
243
|
+
account_internal_error
|
|
244
|
+
thread.kill
|
|
245
|
+
thread.join
|
|
246
|
+
ensure
|
|
247
|
+
@monitor_thread = nil
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def handle_failure(error)
|
|
251
|
+
first_failure = @state != :unsupported
|
|
252
|
+
@state = :unsupported
|
|
253
|
+
@wait_mutex&.synchronize do
|
|
254
|
+
@stopping = true
|
|
255
|
+
@condition&.broadcast
|
|
256
|
+
end
|
|
257
|
+
account_internal_error
|
|
258
|
+
begin
|
|
259
|
+
emit_state(:operation_liveness_unsupported) if first_failure
|
|
260
|
+
rescue StandardError
|
|
261
|
+
nil
|
|
262
|
+
end
|
|
263
|
+
error
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def account_internal_error
|
|
267
|
+
recorder.internal_error!
|
|
268
|
+
rescue StandardError
|
|
269
|
+
nil
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def safe_monotonic_ns
|
|
273
|
+
@clock.monotonic_ns
|
|
274
|
+
rescue StandardError => e
|
|
275
|
+
account_internal_error
|
|
276
|
+
raise e unless fail_open?
|
|
277
|
+
|
|
278
|
+
recorder.session.started_monotonic_ns
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'validation'
|
|
4
|
+
|
|
5
|
+
module FiberAudit
|
|
6
|
+
module Runtime
|
|
7
|
+
OperationLivenessPolicy = Data.define(:enabled, :poll_interval_ms, :long_active_threshold_ms) do
|
|
8
|
+
def initialize(**values)
|
|
9
|
+
unknown = values.keys - OperationLivenessPolicy::DEFAULTS.keys
|
|
10
|
+
raise RuntimeContractError, "unknown operation liveness policy field: #{unknown.first}" unless unknown.empty?
|
|
11
|
+
|
|
12
|
+
fields = OperationLivenessPolicy::DEFAULTS.merge(values)
|
|
13
|
+
raise RuntimeContractError, 'enabled must be a Boolean' unless [true, false].include?(fields[:enabled])
|
|
14
|
+
|
|
15
|
+
limits = OperationLivenessPolicy::LIMITS.to_h do |name, range|
|
|
16
|
+
value = fields.fetch(name)
|
|
17
|
+
unless value.is_a?(Integer) && range.cover?(value)
|
|
18
|
+
raise RuntimeContractError, "#{name} must be an Integer in #{range}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
[name, value]
|
|
22
|
+
end
|
|
23
|
+
super(enabled: fields[:enabled], **limits)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def enabled? = enabled
|
|
27
|
+
def poll_interval_ns = poll_interval_ms * OperationLivenessPolicy::NANOSECONDS_PER_MILLISECOND
|
|
28
|
+
def long_active_threshold_ns = long_active_threshold_ms * OperationLivenessPolicy::NANOSECONDS_PER_MILLISECOND
|
|
29
|
+
|
|
30
|
+
def long_active?(age_ns:)
|
|
31
|
+
Validation.integer(age_ns, 'active operation age') > long_active_threshold_ns
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
OperationLivenessPolicy.const_set(:NANOSECONDS_PER_MILLISECOND, 1_000_000)
|
|
36
|
+
OperationLivenessPolicy.const_set(:DEFAULTS, {
|
|
37
|
+
enabled: true,
|
|
38
|
+
poll_interval_ms: 100,
|
|
39
|
+
long_active_threshold_ms: 1_000
|
|
40
|
+
}.freeze)
|
|
41
|
+
OperationLivenessPolicy.const_set(:LIMITS, {
|
|
42
|
+
poll_interval_ms: 1..60_000,
|
|
43
|
+
long_active_threshold_ms: 1..86_400_000
|
|
44
|
+
}.freeze)
|
|
45
|
+
OperationLivenessPolicy.const_set(:DISABLED, OperationLivenessPolicy.new(enabled: false))
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -7,6 +7,8 @@ require_relative '../execution_context'
|
|
|
7
7
|
require_relative '../rails_integration'
|
|
8
8
|
require_relative '../recorder'
|
|
9
9
|
require_relative '../redactor'
|
|
10
|
+
require_relative '../scheduler_evidence_classifier'
|
|
11
|
+
require_relative '../scheduler_snapshot'
|
|
10
12
|
|
|
11
13
|
module FiberAudit
|
|
12
14
|
module Runtime
|
|
@@ -27,7 +29,8 @@ module FiberAudit
|
|
|
27
29
|
:thread_id,
|
|
28
30
|
:fiber_id,
|
|
29
31
|
:measurements,
|
|
30
|
-
:execution_context
|
|
32
|
+
:execution_context,
|
|
33
|
+
:scheduler_snapshot
|
|
31
34
|
)
|
|
32
35
|
|
|
33
36
|
attr_reader :recorder, :clock, :redactor, :active_operations, :owner_pid, :execution_context_store
|
|
@@ -125,13 +128,15 @@ module FiberAudit
|
|
|
125
128
|
thread = Thread.current
|
|
126
129
|
fiber = Fiber.current
|
|
127
130
|
captured_context = capture_execution_context
|
|
131
|
+
captured_scheduler_snapshot = capture_scheduler_snapshot
|
|
128
132
|
handle = active_operations.register(
|
|
129
133
|
operation: canonical_operation,
|
|
130
134
|
monotonic_ns: started_ns,
|
|
131
135
|
location: location,
|
|
132
136
|
execution_context: captured_context,
|
|
133
137
|
thread: thread,
|
|
134
|
-
fiber: fiber
|
|
138
|
+
fiber: fiber,
|
|
139
|
+
scheduler_snapshot: captured_scheduler_snapshot
|
|
135
140
|
)
|
|
136
141
|
Observation.new(
|
|
137
142
|
operation: canonical_operation,
|
|
@@ -141,7 +146,8 @@ module FiberAudit
|
|
|
141
146
|
thread_id: thread.object_id,
|
|
142
147
|
fiber_id: fiber.object_id,
|
|
143
148
|
measurements: normalized_measurements,
|
|
144
|
-
execution_context: captured_context
|
|
149
|
+
execution_context: captured_context,
|
|
150
|
+
scheduler_snapshot: captured_scheduler_snapshot
|
|
145
151
|
)
|
|
146
152
|
end
|
|
147
153
|
# rubocop:enable Metrics/BlockLength
|
|
@@ -155,6 +161,10 @@ module FiberAudit
|
|
|
155
161
|
Context::UNKNOWN
|
|
156
162
|
end
|
|
157
163
|
|
|
164
|
+
def capture_scheduler_snapshot
|
|
165
|
+
SchedulerSnapshotCapture.capture
|
|
166
|
+
end
|
|
167
|
+
|
|
158
168
|
def emit_start_observation(observation)
|
|
159
169
|
emit_observation(:operation_started, observation, monotonic_ns: observation.started_monotonic_ns)
|
|
160
170
|
rescue StandardError => e
|
|
@@ -207,11 +217,12 @@ module FiberAudit
|
|
|
207
217
|
values.merge!(generated)
|
|
208
218
|
end
|
|
209
219
|
values[:operation_sequence] = observation.handle&.sequence
|
|
210
|
-
values
|
|
220
|
+
enrich_scheduler_evidence(values, observation)
|
|
211
221
|
end
|
|
212
222
|
|
|
213
223
|
def emit_observation(kind, observation, monotonic_ns:, duration_ns: nil, measurements: nil)
|
|
214
224
|
values = measurements || observation.measurements.merge(operation_sequence: observation.handle&.sequence)
|
|
225
|
+
values = enrich_scheduler_evidence(values.dup, observation)
|
|
215
226
|
recorder.record do
|
|
216
227
|
Event.new(
|
|
217
228
|
kind: kind,
|
|
@@ -229,6 +240,18 @@ module FiberAudit
|
|
|
229
240
|
end
|
|
230
241
|
end
|
|
231
242
|
|
|
243
|
+
def enrich_scheduler_evidence(values, observation)
|
|
244
|
+
snapshot = observation.scheduler_snapshot
|
|
245
|
+
values.merge!(snapshot.to_measurements) if snapshot
|
|
246
|
+
values.merge!(
|
|
247
|
+
SchedulerEvidenceClassifier.measurements(
|
|
248
|
+
operation: observation.operation,
|
|
249
|
+
scheduler_snapshot: snapshot
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
values
|
|
253
|
+
end
|
|
254
|
+
|
|
232
255
|
def normalize_measurements(value)
|
|
233
256
|
raise RuntimeContractError, 'probe measurements must be a Hash' unless value.is_a?(Hash)
|
|
234
257
|
|