phronomy 0.7.0 → 0.7.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/.mutant.yml +8 -7
- data/CHANGELOG.md +151 -1
- data/README.md +155 -32
- data/Rakefile +33 -0
- data/benchmark/baseline.json +1 -1
- data/benchmark/bench_regression.rb +1 -0
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
- data/docs/decisions/006-no-built-in-guardrails.md +20 -2
- data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
- data/lib/phronomy/agent/base.rb +250 -65
- data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
- data/lib/phronomy/agent/fsm.rb +41 -64
- data/lib/phronomy/agent/orchestrator.rb +146 -121
- data/lib/phronomy/agent/parallel_tool_chat.rb +79 -22
- data/lib/phronomy/agent/react_agent.rb +8 -0
- data/lib/phronomy/async_queue.rb +155 -0
- data/lib/phronomy/blocking_adapter_pool.rb +435 -0
- data/lib/phronomy/cancellation_scope.rb +123 -0
- data/lib/phronomy/cancellation_token.rb +43 -2
- data/lib/phronomy/concurrency_gate.rb +155 -0
- data/lib/phronomy/configuration.rb +142 -0
- data/lib/phronomy/deadline.rb +63 -0
- data/lib/phronomy/diagnostics.rb +62 -0
- data/lib/phronomy/embeddings/base.rb +17 -0
- data/lib/phronomy/eval/runner.rb +9 -9
- data/lib/phronomy/event_loop.rb +181 -43
- data/lib/phronomy/fsm_session.rb +50 -4
- data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
- data/lib/phronomy/invocation_context.rb +152 -0
- data/lib/phronomy/knowledge_source/base.rb +18 -0
- data/lib/phronomy/llm_adapter/base.rb +104 -0
- data/lib/phronomy/llm_adapter/ruby_llm.rb +41 -0
- data/lib/phronomy/llm_adapter.rb +20 -0
- data/lib/phronomy/metrics.rb +38 -0
- data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
- data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
- data/lib/phronomy/runtime/gate_registry.rb +52 -0
- data/lib/phronomy/runtime/pool_registry.rb +57 -0
- data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
- data/lib/phronomy/runtime/scheduler.rb +98 -0
- data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
- data/lib/phronomy/runtime/task_registry.rb +48 -0
- data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
- data/lib/phronomy/runtime/timer_queue.rb +106 -0
- data/lib/phronomy/runtime/timer_service.rb +42 -0
- data/lib/phronomy/runtime.rb +374 -0
- data/lib/phronomy/task/backend.rb +80 -0
- data/lib/phronomy/task/fiber_backend.rb +157 -0
- data/lib/phronomy/task/immediate_backend.rb +89 -0
- data/lib/phronomy/task/thread_backend.rb +84 -0
- data/lib/phronomy/task.rb +275 -0
- data/lib/phronomy/task_group.rb +265 -0
- data/lib/phronomy/testing/fake_clock.rb +109 -0
- data/lib/phronomy/testing/fake_scheduler.rb +104 -0
- data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
- data/lib/phronomy/testing.rb +12 -0
- data/lib/phronomy/tool/base.rb +110 -2
- data/lib/phronomy/tool/mcp_tool.rb +47 -16
- data/lib/phronomy/tool/scope_policy.rb +50 -0
- data/lib/phronomy/tool_executor.rb +106 -0
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
- data/lib/phronomy/vector_store/async_backend.rb +110 -0
- data/lib/phronomy/vector_store/base.rb +7 -0
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +52 -5
- data/lib/phronomy/workflow_context.rb +29 -2
- data/lib/phronomy/workflow_runner.rb +74 -3
- data/lib/phronomy.rb +42 -0
- metadata +40 -2
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
# A counting semaphore that enforces a concurrency cap across a named
|
|
5
|
+
# resource category (e.g. agent tasks, tool tasks, LLM calls).
|
|
6
|
+
#
|
|
7
|
+
# When +max_concurrent+ is +nil+ the gate is a no-op and all callers
|
|
8
|
+
# pass through immediately without acquiring a slot.
|
|
9
|
+
#
|
|
10
|
+
# Backpressure behaviour when the gate is full is controlled by the
|
|
11
|
+
# +on_full:+ keyword:
|
|
12
|
+
# +:reject+ — raise {Phronomy::BackpressureError} immediately
|
|
13
|
+
# +:wait+ — block the calling fiber/thread until a slot is free
|
|
14
|
+
# +:timeout+ — like +:wait+ but raises {Phronomy::BackpressureError}
|
|
15
|
+
# after +timeout:+ seconds if no slot becomes available
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# gate = Phronomy::ConcurrencyGate.new(max_concurrent: 5, name: :agent)
|
|
19
|
+
# gate.acquire(on_full: :reject) do
|
|
20
|
+
# run_agent_task
|
|
21
|
+
# end
|
|
22
|
+
class ConcurrencyGate
|
|
23
|
+
# @param max_concurrent [Integer, nil] concurrency cap; nil = unlimited
|
|
24
|
+
# @param name [Symbol, String, nil] human-readable label used in error messages
|
|
25
|
+
# @api private
|
|
26
|
+
def initialize(max_concurrent:, name: nil)
|
|
27
|
+
@max = max_concurrent
|
|
28
|
+
@name = name
|
|
29
|
+
@mutex = Mutex.new
|
|
30
|
+
@cond = ConditionVariable.new
|
|
31
|
+
@count = 0
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Returns the configured cap (or nil when unlimited).
|
|
35
|
+
attr_reader :max
|
|
36
|
+
|
|
37
|
+
# Returns the name label.
|
|
38
|
+
attr_reader :name
|
|
39
|
+
|
|
40
|
+
# Returns the number of slots currently in use.
|
|
41
|
+
def current_count
|
|
42
|
+
@mutex.synchronize { @count }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Acquires a slot, executes +block+, then releases the slot.
|
|
46
|
+
# When the gate is unlimited (max is nil) the block runs directly.
|
|
47
|
+
#
|
|
48
|
+
# @param on_full [:reject, :wait, :timeout] backpressure strategy
|
|
49
|
+
# @param timeout [Numeric, nil] seconds before +:timeout+ gives up
|
|
50
|
+
# @yield
|
|
51
|
+
# @return block return value
|
|
52
|
+
# @raise [Phronomy::BackpressureError] when +:reject+ or +:timeout+ fires
|
|
53
|
+
# @api private
|
|
54
|
+
def acquire(on_full: :wait, timeout: nil, &block)
|
|
55
|
+
return block.call if @max.nil?
|
|
56
|
+
|
|
57
|
+
_acquire_slot(on_full: on_full, timeout: timeout)
|
|
58
|
+
begin
|
|
59
|
+
block.call
|
|
60
|
+
ensure
|
|
61
|
+
_release_slot
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def _acquire_slot(on_full:, timeout:)
|
|
68
|
+
scheduler = Phronomy::Runtime::Scheduler.current
|
|
69
|
+
if scheduler
|
|
70
|
+
_acquire_slot_coop(scheduler, on_full: on_full, timeout: timeout)
|
|
71
|
+
else
|
|
72
|
+
_acquire_slot_threaded(on_full: on_full, timeout: timeout)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def _acquire_slot_coop(scheduler, on_full:, timeout:)
|
|
77
|
+
# In cooperative mode all tasks run on the same thread, so no mutex needed.
|
|
78
|
+
deadline = timeout ? (scheduler.virtual_time + timeout) : nil
|
|
79
|
+
@coop_signal ||= scheduler.new_signal
|
|
80
|
+
|
|
81
|
+
loop do
|
|
82
|
+
if @count < @max
|
|
83
|
+
@count += 1
|
|
84
|
+
return
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
case on_full
|
|
88
|
+
when :reject
|
|
89
|
+
raise Phronomy::BackpressureError,
|
|
90
|
+
"ConcurrencyGate[#{@name}] at capacity (#{@max}); " \
|
|
91
|
+
"increase max_concurrent_#{@name}_tasks or retry later"
|
|
92
|
+
when :timeout
|
|
93
|
+
if deadline && scheduler.virtual_time >= deadline
|
|
94
|
+
raise Phronomy::BackpressureError,
|
|
95
|
+
"ConcurrencyGate[#{@name}] timed out waiting for a free slot (cap: #{@max})"
|
|
96
|
+
end
|
|
97
|
+
scheduler.wait_for_signal(@coop_signal)
|
|
98
|
+
if deadline && scheduler.virtual_time >= deadline
|
|
99
|
+
raise Phronomy::BackpressureError,
|
|
100
|
+
"ConcurrencyGate[#{@name}] timed out waiting for a free slot (cap: #{@max})"
|
|
101
|
+
end
|
|
102
|
+
else # :wait
|
|
103
|
+
scheduler.wait_for_signal(@coop_signal)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def _acquire_slot_threaded(on_full:, timeout:)
|
|
109
|
+
deadline = timeout ? (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout) : nil
|
|
110
|
+
|
|
111
|
+
@mutex.synchronize do
|
|
112
|
+
loop do
|
|
113
|
+
if @count < @max
|
|
114
|
+
@count += 1
|
|
115
|
+
return
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
case on_full
|
|
119
|
+
when :reject
|
|
120
|
+
raise Phronomy::BackpressureError,
|
|
121
|
+
"ConcurrencyGate[#{@name}] at capacity (#{@max}); " \
|
|
122
|
+
"increase max_concurrent_#{@name}_tasks or retry later"
|
|
123
|
+
when :timeout
|
|
124
|
+
remaining = deadline ? (deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)) : nil
|
|
125
|
+
if remaining && remaining <= 0
|
|
126
|
+
raise Phronomy::BackpressureError,
|
|
127
|
+
"ConcurrencyGate[#{@name}] timed out waiting for a free slot (cap: #{@max})"
|
|
128
|
+
end
|
|
129
|
+
@cond.wait(@mutex, remaining || nil)
|
|
130
|
+
# re-check deadline after wakeup
|
|
131
|
+
if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
132
|
+
raise Phronomy::BackpressureError,
|
|
133
|
+
"ConcurrencyGate[#{@name}] timed out waiting for a free slot (cap: #{@max})"
|
|
134
|
+
end
|
|
135
|
+
else # :wait
|
|
136
|
+
@cond.wait(@mutex)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def _release_slot
|
|
143
|
+
scheduler = Phronomy::Runtime::Scheduler.current
|
|
144
|
+
if scheduler && @coop_signal
|
|
145
|
+
@count -= 1
|
|
146
|
+
scheduler.raise_signal(@coop_signal)
|
|
147
|
+
else
|
|
148
|
+
@mutex.synchronize do
|
|
149
|
+
@count -= 1
|
|
150
|
+
@cond.signal
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -61,12 +61,154 @@ module Phronomy
|
|
|
61
61
|
# Phronomy.configure { |c| c.state_store = Phronomy::StateStore::InMemory.new }
|
|
62
62
|
attr_accessor :state_store
|
|
63
63
|
|
|
64
|
+
# Maximum byte length of a tool result returned to the LLM.
|
|
65
|
+
# When a tool returns a String longer than this limit, the string is truncated
|
|
66
|
+
# and a warning is logged. Set to +nil+ (default) to disable truncation.
|
|
67
|
+
# @example
|
|
68
|
+
# Phronomy.configure { |c| c.tool_result_max_size = 8192 }
|
|
69
|
+
attr_accessor :tool_result_max_size
|
|
70
|
+
|
|
71
|
+
# LLM adapter used by Agent::Base to perform LLM calls.
|
|
72
|
+
# Must be an instance of a class that inherits from
|
|
73
|
+
# {Phronomy::LLMAdapter::Base}. Defaults to
|
|
74
|
+
# {Phronomy::LLMAdapter::RubyLLM} which delegates to +chat.ask+ via
|
|
75
|
+
# {BlockingAdapterPool}.
|
|
76
|
+
# Set to a custom adapter to swap in an alternative LLM client without
|
|
77
|
+
# changing any agent code.
|
|
78
|
+
# @example
|
|
79
|
+
# Phronomy.configure { |c| c.llm_adapter = MyAsyncLLMAdapter.new }
|
|
80
|
+
attr_accessor :llm_adapter
|
|
81
|
+
|
|
82
|
+
# Default backpressure strategy for {BlockingAdapterPool#submit} when the
|
|
83
|
+
# queue is full. One of +:wait+ (block until a slot is available),
|
|
84
|
+
# +:raise+ (raise {Phronomy::BackpressureError}), or +:timeout+ (raise
|
|
85
|
+
# {Phronomy::TimeoutError} after +backpressure_timeout+ seconds).
|
|
86
|
+
# @return [:wait, :raise, :timeout]
|
|
87
|
+
attr_accessor :backpressure
|
|
88
|
+
|
|
89
|
+
# Seconds to wait before raising {Phronomy::TimeoutError} when
|
|
90
|
+
# +backpressure+ is +:timeout+.
|
|
91
|
+
# @return [Numeric, nil]
|
|
92
|
+
attr_accessor :backpressure_timeout
|
|
93
|
+
|
|
94
|
+
# Warn when an event spends longer than this many seconds waiting in the
|
|
95
|
+
# EventLoop queue before being dispatched (starvation detection).
|
|
96
|
+
# Set to +nil+ to disable the warning.
|
|
97
|
+
# @return [Numeric, nil]
|
|
98
|
+
attr_accessor :event_loop_starvation_threshold_seconds
|
|
99
|
+
|
|
100
|
+
# Warn when processing a single event on the EventLoop thread takes longer
|
|
101
|
+
# than this many seconds (long-running task / blocking-on-loop detection).
|
|
102
|
+
# Set to +nil+ to disable the warning.
|
|
103
|
+
# @return [Numeric, nil]
|
|
104
|
+
attr_accessor :event_loop_dispatch_threshold_seconds
|
|
105
|
+
|
|
106
|
+
# When true, enables all blocking operation diagnostics (Issue #279).
|
|
107
|
+
# Equivalent to setting all diagnostic thresholds to their defaults.
|
|
108
|
+
# @return [Boolean]
|
|
109
|
+
attr_accessor :scheduler_debug
|
|
110
|
+
|
|
111
|
+
# Wall-clock threshold (milliseconds) after which a task that has not
|
|
112
|
+
# yielded the scheduler emits a warning log. nil disables the check.
|
|
113
|
+
# @return [Float, nil]
|
|
114
|
+
attr_accessor :blocking_detect_threshold_ms
|
|
115
|
+
|
|
116
|
+
# Maximum number of concurrent agent tasks (invoke_async calls in-flight).
|
|
117
|
+
# nil = unlimited (default). When at capacity, behaviour is controlled by
|
|
118
|
+
# +backpressure+ (:wait, :raise/:reject, :timeout).
|
|
119
|
+
# @return [Integer, nil]
|
|
120
|
+
attr_accessor :max_concurrent_agent_tasks
|
|
121
|
+
|
|
122
|
+
# Maximum number of concurrent tool tasks (parallel tool calls in-flight).
|
|
123
|
+
# nil = unlimited (default).
|
|
124
|
+
# @return [Integer, nil]
|
|
125
|
+
attr_accessor :max_concurrent_tool_tasks
|
|
126
|
+
|
|
127
|
+
# Maximum number of concurrent workflow tasks.
|
|
128
|
+
# nil = unlimited (default).
|
|
129
|
+
# @return [Integer, nil]
|
|
130
|
+
attr_accessor :max_concurrent_workflow_tasks
|
|
131
|
+
|
|
132
|
+
# Maximum number of concurrent LLM calls in-flight.
|
|
133
|
+
# nil = unlimited (default).
|
|
134
|
+
# @return [Integer, nil]
|
|
135
|
+
attr_accessor :max_concurrent_llm_calls
|
|
136
|
+
|
|
137
|
+
# Upper bound on the number of streaming token chunks that may be buffered
|
|
138
|
+
# in the {AsyncQueue} used by {Agent#stream} before the LLM producer is
|
|
139
|
+
# throttled. When nil (default), the queue is unbounded.
|
|
140
|
+
# @return [Integer, nil]
|
|
141
|
+
attr_accessor :stream_queue_max_size
|
|
142
|
+
|
|
143
|
+
# Maximum number of concurrent RAG knowledge-source fetches in-flight.
|
|
144
|
+
# nil = unlimited (default).
|
|
145
|
+
# @return [Integer, nil]
|
|
146
|
+
attr_accessor :max_concurrent_rag_fetches
|
|
147
|
+
|
|
148
|
+
# Maximum number of concurrent vector-store searches in-flight.
|
|
149
|
+
# nil = unlimited (default).
|
|
150
|
+
# @return [Integer, nil]
|
|
151
|
+
attr_accessor :max_concurrent_vector_searches
|
|
152
|
+
|
|
153
|
+
# Scheduler starvation threshold (milliseconds).
|
|
154
|
+
# When a task waits more than this many milliseconds after calling
|
|
155
|
+
# +runtime.yield+ before being resumed, the wait is counted as a starvation
|
|
156
|
+
# event. Used by the fairness regression test and by the
|
|
157
|
+
# +tasks_waiting_over_threshold+ metric on {Phronomy::Runtime}.
|
|
158
|
+
# Default: 50ms.
|
|
159
|
+
# @return [Numeric]
|
|
160
|
+
attr_accessor :starvation_threshold_ms
|
|
161
|
+
|
|
162
|
+
# Scheduler backend to use for new {Phronomy::Runtime} instances.
|
|
163
|
+
#
|
|
164
|
+
# | Value | Scheduler | Typical use |
|
|
165
|
+
# |-------|-----------|-------------|
|
|
166
|
+
# | +:thread+ | {Runtime::ThreadScheduler} | **Default** — production-ready; one OS thread per task |
|
|
167
|
+
# | +:immediate+ | {Runtime::FakeScheduler} | Tests — tasks run synchronously, no extra threads |
|
|
168
|
+
# | +:fiber+ | {Runtime::DeterministicScheduler} (autorun) | **EXPERIMENTAL** — Fiber-based cooperative scheduler; do not use as production default |
|
|
169
|
+
# | +:cooperative+ | {Runtime::FakeScheduler} | **Deprecated** — alias for +:immediate+; do not use in new code |
|
|
170
|
+
#
|
|
171
|
+
# The default is +:thread+. The +:fiber+ backend remains experimental and opt-in;
|
|
172
|
+
# it will not become the default until integration test coverage is production grade
|
|
173
|
+
# and virtual-time/timeout semantics are fully resolved (see Issues #350, #347, #348).
|
|
174
|
+
#
|
|
175
|
+
# When this setting is changed, the change only takes effect on the NEXT
|
|
176
|
+
# call to {Runtime.instance} that auto-creates a new instance (i.e. after the
|
|
177
|
+
# previous instance has been replaced or reset). To replace the current
|
|
178
|
+
# instance immediately call +Phronomy::Runtime.instance = nil+ first.
|
|
179
|
+
#
|
|
180
|
+
# @return [:thread, :immediate, :fiber]
|
|
181
|
+
attr_accessor :runtime_backend
|
|
182
|
+
|
|
183
|
+
# When +true+, calling {Agent#invoke} from inside a scheduler task
|
|
184
|
+
# raises {SchedulerReentrancyError}. When +false+ (default), a warning
|
|
185
|
+
# is logged instead so that existing callers have time to migrate.
|
|
186
|
+
# @return [Boolean]
|
|
187
|
+
attr_accessor :strict_runtime_guards
|
|
188
|
+
|
|
64
189
|
def initialize
|
|
65
190
|
@recursion_limit = 25
|
|
66
191
|
@tracer = Phronomy::Tracing::NullTracer.new
|
|
67
192
|
@trace_pii = false
|
|
68
193
|
@event_loop = false
|
|
69
194
|
@event_loop_stop_grace_seconds = 5
|
|
195
|
+
@llm_adapter = Phronomy::LLMAdapter::RubyLLM.new
|
|
196
|
+
@backpressure = :wait
|
|
197
|
+
@backpressure_timeout = nil
|
|
198
|
+
@event_loop_starvation_threshold_seconds = nil
|
|
199
|
+
@event_loop_dispatch_threshold_seconds = nil
|
|
200
|
+
@scheduler_debug = false
|
|
201
|
+
@blocking_detect_threshold_ms = nil
|
|
202
|
+
@max_concurrent_agent_tasks = nil
|
|
203
|
+
@max_concurrent_tool_tasks = nil
|
|
204
|
+
@max_concurrent_workflow_tasks = nil
|
|
205
|
+
@max_concurrent_llm_calls = nil
|
|
206
|
+
@stream_queue_max_size = nil
|
|
207
|
+
@max_concurrent_rag_fetches = nil
|
|
208
|
+
@max_concurrent_vector_searches = nil
|
|
209
|
+
@starvation_threshold_ms = 50
|
|
210
|
+
@runtime_backend = :thread
|
|
211
|
+
@strict_runtime_guards = false
|
|
70
212
|
end
|
|
71
213
|
end
|
|
72
214
|
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
# A point in time used as an upper bound for an operation.
|
|
5
|
+
#
|
|
6
|
+
# Uses the monotonic clock (+Process::CLOCK_MONOTONIC+) internally to avoid
|
|
7
|
+
# skew from NTP adjustments or DST transitions.
|
|
8
|
+
#
|
|
9
|
+
# @example Create a 30-second deadline and check remaining time
|
|
10
|
+
# deadline = Phronomy::Deadline.in(30)
|
|
11
|
+
# sleep 1
|
|
12
|
+
# deadline.remaining_seconds # => ~29.0
|
|
13
|
+
# deadline.expired? # => false
|
|
14
|
+
class Deadline
|
|
15
|
+
# Creates a deadline that expires +seconds+ from now.
|
|
16
|
+
#
|
|
17
|
+
# @param seconds [Numeric] seconds from now until expiry
|
|
18
|
+
# @return [Deadline]
|
|
19
|
+
# @api private
|
|
20
|
+
def self.in(seconds)
|
|
21
|
+
new(Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param monotonic_at [Float] absolute monotonic timestamp of expiry
|
|
25
|
+
# @api private
|
|
26
|
+
def initialize(monotonic_at)
|
|
27
|
+
@monotonic_at = monotonic_at
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns +true+ when the deadline has passed.
|
|
31
|
+
# @return [Boolean]
|
|
32
|
+
# @api private
|
|
33
|
+
def expired?
|
|
34
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @monotonic_at
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Seconds remaining until expiry. Returns 0 when already expired.
|
|
38
|
+
# @return [Float]
|
|
39
|
+
# @api private
|
|
40
|
+
def remaining_seconds
|
|
41
|
+
remaining = @monotonic_at - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
42
|
+
[remaining, 0.0].max
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Attaches this deadline to a {CancellationToken} by cancelling the token
|
|
46
|
+
# when the deadline expires. Uses the Runtime timer queue (a single
|
|
47
|
+
# background thread shared by all deadlines) instead of spawning one thread
|
|
48
|
+
# per deadline.
|
|
49
|
+
#
|
|
50
|
+
# @param token [CancellationToken]
|
|
51
|
+
# @param timer_queue [Runtime::TimerQueue, nil] queue to register with;
|
|
52
|
+
# defaults to +Phronomy::Runtime.instance.timer_queue+
|
|
53
|
+
# @return [self]
|
|
54
|
+
# @api private
|
|
55
|
+
def attach_to(token, timer_queue: Phronomy::Runtime.instance.timer_queue)
|
|
56
|
+
seconds = remaining_seconds
|
|
57
|
+
return self if seconds <= 0
|
|
58
|
+
|
|
59
|
+
timer_queue.schedule(seconds: seconds) { token.cancel! }
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
# Developer-facing diagnostics for blocking operation detection (Issue #279).
|
|
5
|
+
#
|
|
6
|
+
# Provides debug dump utilities that can be called from an IRB / Rails console
|
|
7
|
+
# or in test helpers to inspect the current state of the Runtime.
|
|
8
|
+
#
|
|
9
|
+
# @example Enable diagnostics and print a dump
|
|
10
|
+
# Phronomy.configure { |c| c.scheduler_debug = true }
|
|
11
|
+
# Phronomy::Diagnostics.dump
|
|
12
|
+
module Diagnostics
|
|
13
|
+
# Prints a formatted summary of the current Runtime state to +$stderr+
|
|
14
|
+
# (or the supplied IO).
|
|
15
|
+
#
|
|
16
|
+
# Includes:
|
|
17
|
+
# - BlockingAdapterPool: active workers, queue depth, abandoned count
|
|
18
|
+
# - EventLoop: last / max / average lag in milliseconds
|
|
19
|
+
#
|
|
20
|
+
# @param out [IO] output destination (default: $stderr)
|
|
21
|
+
# @return [void]
|
|
22
|
+
# @api public
|
|
23
|
+
def self.dump(out: $stderr)
|
|
24
|
+
snap = Phronomy::Metrics.snapshot
|
|
25
|
+
|
|
26
|
+
out.puts "[Phronomy::Diagnostics] Runtime state dump"
|
|
27
|
+
out.puts " BlockingAdapterPool:"
|
|
28
|
+
out.puts " pool_size : #{snap[:blocking_pool_size]}"
|
|
29
|
+
out.puts " active_count : #{snap[:blocking_pool_active]}"
|
|
30
|
+
out.puts " queue_depth : #{snap[:blocking_pool_queue_length]}"
|
|
31
|
+
out.puts " abandoned_total : #{snap[:blocking_pool_abandoned_total]}"
|
|
32
|
+
out.puts " EventLoop:"
|
|
33
|
+
out.puts " last_lag_ms : #{snap[:event_loop_lag_last_ms]}"
|
|
34
|
+
out.puts " max_lag_ms : #{snap[:event_loop_lag_max_ms]}"
|
|
35
|
+
out.puts " average_lag_ms : #{snap[:event_loop_lag_average_ms]}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns the diagnostics state as a plain Hash (useful for JSON export).
|
|
39
|
+
#
|
|
40
|
+
# @return [Hash]
|
|
41
|
+
# @api public
|
|
42
|
+
def self.snapshot
|
|
43
|
+
Phronomy::Metrics.snapshot
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Raises an error if +invoke+ (blocking) is called from inside an EventLoop
|
|
47
|
+
# action, preventing accidental scheduler stalls.
|
|
48
|
+
#
|
|
49
|
+
# Called by Agent::Base#invoke and Workflow#invoke before executing.
|
|
50
|
+
#
|
|
51
|
+
# @raise [Phronomy::SchedulerReentrancyError] when called from EventLoop thread
|
|
52
|
+
# @return [void]
|
|
53
|
+
# @api private
|
|
54
|
+
def self.assert_not_in_event_loop!
|
|
55
|
+
return unless Phronomy::EventLoop.current?
|
|
56
|
+
|
|
57
|
+
raise Phronomy::SchedulerReentrancyError,
|
|
58
|
+
"Blocking invoke called from inside an EventLoop action. " \
|
|
59
|
+
"Use invoke_async instead."
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -17,6 +17,23 @@ module Phronomy
|
|
|
17
17
|
cancellation_token&.raise_if_cancelled!
|
|
18
18
|
raise NotImplementedError, "#{self.class}#embed is not implemented"
|
|
19
19
|
end
|
|
20
|
+
|
|
21
|
+
# Submits an {#embed} call to {BlockingAdapterPool} and returns a
|
|
22
|
+
# {BlockingAdapterPool::PendingOperation}.
|
|
23
|
+
#
|
|
24
|
+
# @param text [String]
|
|
25
|
+
# @param cancellation_token [Phronomy::CancellationToken, nil]
|
|
26
|
+
# @param timeout [Numeric, nil] seconds before the operation is abandoned
|
|
27
|
+
# @return [BlockingAdapterPool::PendingOperation]
|
|
28
|
+
# @api public
|
|
29
|
+
def embed_async(text, cancellation_token = nil, timeout: nil)
|
|
30
|
+
Phronomy::Runtime.instance.blocking_io.submit(
|
|
31
|
+
timeout: timeout,
|
|
32
|
+
cancellation_token: cancellation_token
|
|
33
|
+
) do
|
|
34
|
+
embed(text, cancellation_token)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
20
37
|
end
|
|
21
38
|
end
|
|
22
39
|
end
|
data/lib/phronomy/eval/runner.rb
CHANGED
|
@@ -32,25 +32,25 @@ module Phronomy
|
|
|
32
32
|
cases = dataset.to_a
|
|
33
33
|
return cases.map { |eval_case| run_one(eval_case, callable) } if concurrency <= 1
|
|
34
34
|
|
|
35
|
-
# Run cases in slices of +concurrency+
|
|
36
|
-
# before the next starts, bounding peak
|
|
37
|
-
# Writing to pre-allocated slots (one per
|
|
38
|
-
#
|
|
35
|
+
# Run cases in slices of +concurrency+ tasks. Each slice is joined
|
|
36
|
+
# before the next starts, bounding peak task count to +concurrency+.
|
|
37
|
+
# Writing to pre-allocated slots (one per task) is safe because each
|
|
38
|
+
# task writes to a unique index and all tasks in a slice are joined
|
|
39
39
|
# before the next slice begins.
|
|
40
|
-
# Exceptions in worker
|
|
41
|
-
#
|
|
40
|
+
# Exceptions in worker tasks are collected and re-raised after all
|
|
41
|
+
# tasks in the slice are joined, preventing orphaned tasks.
|
|
42
42
|
results = Array.new(cases.length)
|
|
43
43
|
cases.each_with_index.each_slice(concurrency) do |batch|
|
|
44
44
|
errors = []
|
|
45
45
|
errors_mu = Mutex.new
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
tasks = batch.map do |eval_case, i|
|
|
47
|
+
Phronomy::Runtime.instance.spawn(name: "eval-case-#{i}") do
|
|
48
48
|
results[i] = run_one(eval_case, callable)
|
|
49
49
|
rescue => e
|
|
50
50
|
errors_mu.synchronize { errors << e }
|
|
51
51
|
end
|
|
52
52
|
end
|
|
53
|
-
|
|
53
|
+
tasks.each(&:join)
|
|
54
54
|
raise errors.first if errors.any?
|
|
55
55
|
end
|
|
56
56
|
results
|