phronomy 0.16.0 → 0.18.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/.mutant.yml +8 -9
- data/CHANGELOG.md +151 -1134
- data/CONTRIBUTING.md +28 -16
- data/README.md +69 -1249
- data/benchmark/baseline.json +2 -3
- data/benchmark/bench_agent_invoke.rb +4 -4
- data/benchmark/bench_context_assembler.rb +134 -34
- data/benchmark/bench_regression.rb +26 -6
- data/benchmark/bench_tool_schema.rb +2 -35
- data/docs/changelog/0.14-and-earlier.md +1137 -0
- data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
- data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
- data/docs/decisions/010-cooperative-first-concurrency.md +155 -228
- data/docs/decisions/011-build-context-as-single-llm-input-authority.md +2 -2
- data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
- data/docs/features.md +87 -0
- data/docs/getting-started.md +351 -0
- data/docs/migrations/0.15.md +35 -0
- data/docs/migrations/0.16.md +43 -0
- data/docs/runtime-and-concurrency.md +258 -0
- data/examples/workflows/generic_task_event_mapping.rb +14 -6
- data/lib/phronomy/agent/agent_invocation.rb +2 -36
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +157 -94
- data/lib/phronomy/agent/agent_root.rb +1 -2
- data/lib/phronomy/agent/async_event_api.rb +3 -3
- data/lib/phronomy/agent/base.rb +170 -333
- data/lib/phronomy/agent/context/capability/base.rb +177 -298
- data/lib/phronomy/agent/context_assembler.rb +65 -29
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
- data/lib/phronomy/agent/context_plan_validator.rb +0 -33
- data/lib/phronomy/agent/execution_coordinator.rb +6 -7
- data/lib/phronomy/agent/journal_projection.rb +28 -2
- data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
- data/lib/phronomy/agent/shared_state.rb +48 -138
- data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
- data/lib/phronomy/agent/tool_executor.rb +29 -71
- data/lib/phronomy/agent/tool_invocation.rb +180 -336
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
- data/lib/phronomy/agent.rb +6 -10
- data/lib/phronomy/configuration.rb +4 -171
- data/lib/phronomy/diagnostics.rb +12 -41
- data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
- data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +51 -79
- data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
- data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
- data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
- data/lib/phronomy/engine/event_loop.rb +89 -190
- data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
- data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
- data/lib/phronomy/engine/runtime.rb +49 -377
- data/lib/phronomy/engine/task.rb +136 -277
- data/lib/phronomy/llm_adapter/base.rb +14 -14
- data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
- data/lib/phronomy/llm_adapter.rb +2 -2
- data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
- data/lib/phronomy/metrics.rb +15 -30
- data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
- data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
- data/lib/phronomy/multi_agent/handoff.rb +1 -0
- data/lib/phronomy/multi_agent/orchestrator.rb +252 -256
- data/lib/phronomy/multi_agent/team_coordinator.rb +44 -133
- data/lib/phronomy/testing/eval/comparison.rb +23 -0
- data/lib/phronomy/testing/eval/dataset.rb +27 -0
- data/lib/phronomy/testing/eval/eval_case.rb +13 -0
- data/lib/phronomy/testing/eval/eval_result.rb +16 -0
- data/lib/phronomy/testing/eval/metrics.rb +43 -0
- data/lib/phronomy/testing/eval/runner.rb +52 -0
- data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
- data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
- data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
- data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
- data/lib/phronomy/testing/eval/scorer.rb +10 -0
- data/lib/phronomy/testing/eval.rb +9 -0
- data/lib/phronomy/testing/fake_clock.rb +6 -53
- data/lib/phronomy/testing.rb +2 -6
- data/lib/phronomy/tools/agent.rb +141 -6
- data/lib/phronomy/vector_store/async_backend.rb +21 -17
- data/lib/phronomy/vector_store/base.rb +2 -2
- data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
- data/lib/phronomy/vector_store/in_memory.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow_runner.rb +2 -4
- data/lib/phronomy.rb +7 -121
- data/scripts/api_snapshot.rb +4 -15
- metadata +24 -38
- data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
- data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
- data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
- data/lib/phronomy/agent/fsm_runtime_adapter.rb +0 -210
- data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
- data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
- data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
- data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
- data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
- data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
- data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
- data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
- data/lib/phronomy/engine/task/backend.rb +0 -80
- data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
- data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
- data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
- data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
- data/lib/phronomy/engine/task/thread_backend.rb +0 -84
- data/lib/phronomy/engine/task_group.rb +0 -265
- data/lib/phronomy/eval/comparison.rb +0 -47
- data/lib/phronomy/eval/dataset.rb +0 -45
- data/lib/phronomy/eval/eval_case.rb +0 -17
- data/lib/phronomy/eval/eval_result.rb +0 -29
- data/lib/phronomy/eval/metrics.rb +0 -66
- data/lib/phronomy/eval/runner.rb +0 -94
- data/lib/phronomy/eval/scorer/base.rb +0 -22
- data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
- data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
- data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
- data/lib/phronomy/eval/scorer.rb +0 -9
- data/lib/phronomy/eval.rb +0 -7
- data/lib/phronomy/knowledge_source.rb +0 -12
- data/lib/phronomy/llm_context_window/assembler.rb +0 -191
- data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
- data/lib/phronomy/testing/fake_scheduler.rb +0 -104
- data/lib/phronomy/testing/scheduler_helpers.rb +0 -68
|
@@ -1,561 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Concurrency
|
|
5
|
-
# A bounded, observable thread pool for blocking I/O operations.
|
|
6
|
-
#
|
|
7
|
-
# ## Architectural boundary
|
|
8
|
-
#
|
|
9
|
-
# `BlockingAdapterPool` is the *only* place in Phronomy that uses raw OS threads
|
|
10
|
-
# for I/O. All third-party gem calls whose internal I/O Phronomy cannot control
|
|
11
|
-
# — including RubyLLM, ActiveRecord, Redis, Faraday, and MCP stdio transport —
|
|
12
|
-
# **must** route through this pool (or a named pool obtained via
|
|
13
|
-
# {Runtime#pool}). Custom non-blocking HTTP/selector runtimes are intentionally
|
|
14
|
-
# out of scope; the pool + cooperative scheduler combination satisfies all
|
|
15
|
-
# current concurrency requirements without that complexity. (See ADR-010.)
|
|
16
|
-
#
|
|
17
|
-
# All blocking calls (LLM HTTP, MCP stdio, ActiveRecord, Redis, etc.) must be
|
|
18
|
-
# submitted through this pool so that:
|
|
19
|
-
#
|
|
20
|
-
# 1. The total number of OS threads is capped.
|
|
21
|
-
# 2. Queue depth is bounded (backpressure when the pool is saturated).
|
|
22
|
-
# 3. Per-operation timeouts are enforced consistently.
|
|
23
|
-
# 4. Abandoned (timed-out) operations are tracked and logged.
|
|
24
|
-
# 5. Metrics (active count, queue depth, abandoned count, avg wait time) are
|
|
25
|
-
# observable at runtime.
|
|
26
|
-
#
|
|
27
|
-
# @example Submitting a blocking LLM call
|
|
28
|
-
# op = runtime.blocking_io.submit(timeout: 30) { chat.ask(message) }
|
|
29
|
-
# result = op.blocking_wait # blocks the calling thread until done
|
|
30
|
-
#
|
|
31
|
-
# @example With cancellation
|
|
32
|
-
# token = Phronomy::Concurrency::CancellationToken.timeout_after(60)
|
|
33
|
-
# op = pool.submit(timeout: 30, cancellation_token: token) { expensive_call }
|
|
34
|
-
# result = op.blocking_wait
|
|
35
|
-
class BlockingAdapterPool
|
|
36
|
-
# Represents the pending result of a submitted blocking operation.
|
|
37
|
-
# Returned immediately by {BlockingAdapterPool#submit}; call {#blocking_wait}
|
|
38
|
-
# to wait for the result.
|
|
39
|
-
class PendingOperation
|
|
40
|
-
# @return [Boolean] true when the caller-facing result has settled
|
|
41
|
-
# (success, failure, cancellation, or submit-time timeout)
|
|
42
|
-
# @api private
|
|
43
|
-
def done?
|
|
44
|
-
@mutex.synchronize { @done }
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# @return [Boolean] true when the submit-time deadline settled the operation
|
|
48
|
-
# @api private
|
|
49
|
-
def timed_out?
|
|
50
|
-
@mutex.synchronize { @timed_out }
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
# @return [Boolean] true when a submit-time timeout occurred after worker
|
|
54
|
-
# execution had started. The worker is not forcibly interrupted.
|
|
55
|
-
# @api private
|
|
56
|
-
def abandoned?
|
|
57
|
-
@mutex.synchronize { @abandoned }
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
# @return [Float] seconds spent in the queue before execution started
|
|
61
|
-
# @api private
|
|
62
|
-
def wait_time
|
|
63
|
-
@wait_time || 0.0
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
# Blocks until the operation completes and returns its value.
|
|
67
|
-
#
|
|
68
|
-
# A +timeout+ passed here is local to this waiter. When it expires,
|
|
69
|
-
# {Phronomy::TimeoutError} is raised to this caller, but the operation is not
|
|
70
|
-
# settled, marked abandoned, or otherwise changed. The worker continues, and
|
|
71
|
-
# another waiter or an +on_complete+ callback may receive the eventual result
|
|
72
|
-
# unless the submit-time deadline or cancellation settles the operation first.
|
|
73
|
-
#
|
|
74
|
-
# A submit-time timeout passed to {BlockingAdapterPool#submit} is enforced by
|
|
75
|
-
# the runtime timer queue independently of this method and is therefore not
|
|
76
|
-
# re-read here.
|
|
77
|
-
#
|
|
78
|
-
# An optional +cancellation_token+ may be passed here (or at submit time).
|
|
79
|
-
# If the token is cancelled while waiting, {Phronomy::CancellationError} is
|
|
80
|
-
# raised without interrupting the worker.
|
|
81
|
-
#
|
|
82
|
-
# **Cooperative path (`:fiber` / `DeterministicScheduler`):**
|
|
83
|
-
# When called from a Fiber managed by {DeterministicScheduler}, the calling
|
|
84
|
-
# Fiber suspends cooperatively via +Fiber.yield+ rather than blocking the OS
|
|
85
|
-
# thread. The Fiber is resumed through +on_complete+ when the operation
|
|
86
|
-
# settles. A waiter-local +timeout:+ is not enforced on this path; use the
|
|
87
|
-
# submit-time timeout for an operation-wide deadline.
|
|
88
|
-
#
|
|
89
|
-
# @param timeout [Numeric, nil] maximum seconds this waiter will block
|
|
90
|
-
# (thread path only; ignored on the cooperative/fiber path)
|
|
91
|
-
# @param cancellation_token [CancellationToken, nil]
|
|
92
|
-
# @return [Object]
|
|
93
|
-
# @raise [Phronomy::TimeoutError]
|
|
94
|
-
# @raise [Phronomy::CancellationError]
|
|
95
|
-
# @raise [Exception] error raised inside the submitted block
|
|
96
|
-
# @api private
|
|
97
|
-
def blocking_wait(timeout: nil, cancellation_token: nil)
|
|
98
|
-
effective_token = cancellation_token || @cancellation_token
|
|
99
|
-
|
|
100
|
-
raise CancellationError, "blocking operation cancelled" if effective_token&.cancelled?
|
|
101
|
-
|
|
102
|
-
# Cooperative context: suspend the calling Fiber rather than blocking
|
|
103
|
-
# the OS thread so that DeterministicScheduler can continue dispatching
|
|
104
|
-
# other tasks while waiting for the blocking worker or submit-time timer.
|
|
105
|
-
scheduler = Thread.current.thread_variable_get(:phronomy_deterministic_scheduler)
|
|
106
|
-
in_managed_fiber = !Fiber.respond_to?(:main) || Fiber.current != Fiber.main
|
|
107
|
-
if scheduler && in_managed_fiber
|
|
108
|
-
unless done?
|
|
109
|
-
scheduler.track_blocking_await
|
|
110
|
-
waiting_fiber = Fiber.current
|
|
111
|
-
on_complete do |_result, _error|
|
|
112
|
-
scheduler.complete_blocking_await
|
|
113
|
-
scheduler.enqueue_fiber(-> { waiting_fiber.resume })
|
|
114
|
-
end
|
|
115
|
-
Fiber.yield(:cooperative_suspend)
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
raise CancellationError, "blocking operation cancelled" if effective_token&.cancelled?
|
|
119
|
-
|
|
120
|
-
value, error = @mutex.synchronize { [@value, @error] }
|
|
121
|
-
raise error if error
|
|
122
|
-
|
|
123
|
-
return value
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
# Wake up the waiting thread whenever the token is cancelled so we can
|
|
127
|
-
# propagate cancellation without sleeping until the operation completes.
|
|
128
|
-
effective_token&.on_cancel { @mutex.synchronize { @cond.broadcast } }
|
|
129
|
-
|
|
130
|
-
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout if timeout
|
|
131
|
-
value, error = @mutex.synchronize do
|
|
132
|
-
until @done
|
|
133
|
-
raise CancellationError, "blocking operation cancelled" if effective_token&.cancelled?
|
|
134
|
-
|
|
135
|
-
if deadline
|
|
136
|
-
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
137
|
-
if remaining <= 0
|
|
138
|
-
raise Phronomy::TimeoutError, "timed out waiting for blocking operation after #{timeout}s"
|
|
139
|
-
end
|
|
140
|
-
@cond.wait(@mutex, remaining)
|
|
141
|
-
else
|
|
142
|
-
@cond.wait(@mutex)
|
|
143
|
-
end
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
[@value, @error]
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
raise error if error
|
|
150
|
-
|
|
151
|
-
value
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
# Unified wait interface compatible with {Phronomy::Task#wait_result}.
|
|
155
|
-
alias_method :wait_result, :blocking_wait
|
|
156
|
-
|
|
157
|
-
# Registers a callback to be called when the operation settles.
|
|
158
|
-
#
|
|
159
|
-
# If the operation has already settled, the callback is invoked immediately
|
|
160
|
-
# on the calling thread. Otherwise it may be invoked on a pool worker thread
|
|
161
|
-
# or on the runtime timer thread. The execution thread is not guaranteed;
|
|
162
|
-
# callbacks must be thread-safe and should complete quickly.
|
|
163
|
-
#
|
|
164
|
-
# The callback receives +result+ and +error+ (one of them will be +nil+).
|
|
165
|
-
#
|
|
166
|
-
# @yield [result, error]
|
|
167
|
-
# @return [self]
|
|
168
|
-
# @api private
|
|
169
|
-
def on_complete(&callback)
|
|
170
|
-
fire_args = nil
|
|
171
|
-
@mutex.synchronize do
|
|
172
|
-
if @done
|
|
173
|
-
fire_args = [@value, @error]
|
|
174
|
-
else
|
|
175
|
-
@callbacks ||= []
|
|
176
|
-
@callbacks << callback
|
|
177
|
-
end
|
|
178
|
-
end
|
|
179
|
-
callback.call(*fire_args) if fire_args
|
|
180
|
-
self
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
# @api private
|
|
184
|
-
def initialize(block, timeout: nil, cancellation_token: nil, on_abandoned: nil, submitted_at: nil)
|
|
185
|
-
@block = block
|
|
186
|
-
@timeout = timeout
|
|
187
|
-
@cancellation_token = cancellation_token
|
|
188
|
-
@on_abandoned = on_abandoned
|
|
189
|
-
@value = nil
|
|
190
|
-
@error = nil
|
|
191
|
-
@done = false
|
|
192
|
-
@timed_out = false
|
|
193
|
-
@started = false
|
|
194
|
-
@abandoned = false
|
|
195
|
-
@wait_time = nil
|
|
196
|
-
@submitted_at = submitted_at || Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
197
|
-
@mutex = Mutex.new
|
|
198
|
-
@cond = ConditionVariable.new
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
# Settles the operation with a submit-time timeout.
|
|
202
|
-
#
|
|
203
|
-
# The worker is not interrupted. If execution has already started, the
|
|
204
|
-
# operation is marked abandoned and the worker's eventual result is discarded.
|
|
205
|
-
#
|
|
206
|
-
# @return [Boolean] true when this call settled the operation, false when the
|
|
207
|
-
# operation had already settled
|
|
208
|
-
# @api private
|
|
209
|
-
def fire_timeout!
|
|
210
|
-
error = Phronomy::TimeoutError.new(
|
|
211
|
-
"blocking operation timed out after #{@timeout}s"
|
|
212
|
-
)
|
|
213
|
-
callbacks = nil
|
|
214
|
-
abandoned_now = false
|
|
215
|
-
|
|
216
|
-
@mutex.synchronize do
|
|
217
|
-
return false if @done
|
|
218
|
-
|
|
219
|
-
@done = true
|
|
220
|
-
@timed_out = true
|
|
221
|
-
@error = error
|
|
222
|
-
@abandoned = @started
|
|
223
|
-
abandoned_now = @abandoned
|
|
224
|
-
@cond.broadcast
|
|
225
|
-
callbacks = @callbacks
|
|
226
|
-
@callbacks = nil
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
# Internal bookkeeping is completed before user callbacks run. A metrics
|
|
230
|
-
# callback must never suppress delivery of TimeoutError to on_complete.
|
|
231
|
-
if abandoned_now
|
|
232
|
-
begin
|
|
233
|
-
@on_abandoned&.call
|
|
234
|
-
rescue => e
|
|
235
|
-
Phronomy.configuration.logger&.error {
|
|
236
|
-
"BlockingAdapterPool abandoned callback failed: #{e.class}: #{e.message}"
|
|
237
|
-
}
|
|
238
|
-
end
|
|
239
|
-
end
|
|
240
|
-
|
|
241
|
-
callbacks&.each { |callback| callback.call(nil, error) }
|
|
242
|
-
true
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
# Marks an operation that could not be admitted to the pool as settled, so a
|
|
246
|
-
# previously armed submit-time timer becomes a harmless no-op.
|
|
247
|
-
#
|
|
248
|
-
# @param error [Exception, nil]
|
|
249
|
-
# @return [Boolean] true when this call changed the state
|
|
250
|
-
# @api private
|
|
251
|
-
def fail_submission!(error = nil)
|
|
252
|
-
@mutex.synchronize do
|
|
253
|
-
return false if @done
|
|
254
|
-
|
|
255
|
-
@done = true
|
|
256
|
-
@error = error if error
|
|
257
|
-
@cond.broadcast
|
|
258
|
-
end
|
|
259
|
-
true
|
|
260
|
-
end
|
|
261
|
-
|
|
262
|
-
# Executes the operation on a pool worker.
|
|
263
|
-
# @api private
|
|
264
|
-
def execute!
|
|
265
|
-
@wait_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @submitted_at
|
|
266
|
-
|
|
267
|
-
cancellation_error = nil
|
|
268
|
-
callbacks = nil
|
|
269
|
-
should_run = @mutex.synchronize do
|
|
270
|
-
if @done
|
|
271
|
-
false
|
|
272
|
-
elsif @cancellation_token&.cancelled?
|
|
273
|
-
cancellation_error = CancellationError.new("operation cancelled before execution")
|
|
274
|
-
@done = true
|
|
275
|
-
@error = cancellation_error
|
|
276
|
-
@cond.broadcast
|
|
277
|
-
callbacks = @callbacks
|
|
278
|
-
@callbacks = nil
|
|
279
|
-
false
|
|
280
|
-
else
|
|
281
|
-
# Linearization point: after this assignment, a concurrent timeout is
|
|
282
|
-
# classified as an in-flight abandonment and the block will run.
|
|
283
|
-
@started = true
|
|
284
|
-
true
|
|
285
|
-
end
|
|
286
|
-
end
|
|
287
|
-
|
|
288
|
-
if cancellation_error
|
|
289
|
-
callbacks&.each { |callback| callback.call(nil, cancellation_error) }
|
|
290
|
-
return
|
|
291
|
-
end
|
|
292
|
-
|
|
293
|
-
return unless should_run
|
|
294
|
-
|
|
295
|
-
# Do NOT use Timeout.timeout here — it delivers an async Thread#raise
|
|
296
|
-
# that can corrupt external library state (mutexes, C extensions, etc.).
|
|
297
|
-
# Each blocking library should set its own native connection/read timeout.
|
|
298
|
-
begin
|
|
299
|
-
complete_with_value!(@block.call)
|
|
300
|
-
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
301
|
-
# Rescue all Exception subclasses so non-StandardError raises still
|
|
302
|
-
# settle the operation and unblock waiters.
|
|
303
|
-
complete_with_error!(e)
|
|
304
|
-
raise if e.is_a?(SignalException) || e.is_a?(SystemExit)
|
|
305
|
-
end
|
|
306
|
-
end
|
|
307
|
-
|
|
308
|
-
private
|
|
309
|
-
|
|
310
|
-
def complete_with_value!(value)
|
|
311
|
-
callbacks = nil
|
|
312
|
-
@mutex.synchronize do
|
|
313
|
-
return false if @done
|
|
314
|
-
|
|
315
|
-
@value = value
|
|
316
|
-
@done = true
|
|
317
|
-
@cond.broadcast
|
|
318
|
-
callbacks = @callbacks
|
|
319
|
-
@callbacks = nil
|
|
320
|
-
end
|
|
321
|
-
callbacks&.each { |callback| callback.call(value, nil) }
|
|
322
|
-
true
|
|
323
|
-
end
|
|
324
|
-
|
|
325
|
-
def complete_with_error!(error)
|
|
326
|
-
callbacks = nil
|
|
327
|
-
@mutex.synchronize do
|
|
328
|
-
return false if @done
|
|
329
|
-
|
|
330
|
-
@error = error
|
|
331
|
-
@done = true
|
|
332
|
-
@cond.broadcast
|
|
333
|
-
callbacks = @callbacks
|
|
334
|
-
@callbacks = nil
|
|
335
|
-
end
|
|
336
|
-
callbacks&.each { |callback| callback.call(nil, error) }
|
|
337
|
-
true
|
|
338
|
-
end
|
|
339
|
-
end
|
|
340
|
-
|
|
341
|
-
# @param pool_size [Integer] maximum number of worker threads
|
|
342
|
-
# @param queue_size [Integer] maximum pending operations waiting for a worker
|
|
343
|
-
# @param name [String, Symbol, nil] optional pool name used in thread labels
|
|
344
|
-
# @param logger [Logger, nil] optional logger for warnings
|
|
345
|
-
# @param timer_queue_provider [#call, nil] returns a TimerQueue-compatible
|
|
346
|
-
# object. Required when +submit(timeout:)+ is used.
|
|
347
|
-
# @api private
|
|
348
|
-
def initialize(pool_size: 10, queue_size: 100, name: nil, logger: nil, timer_queue_provider: nil)
|
|
349
|
-
@pool_size = pool_size
|
|
350
|
-
@queue_size = queue_size
|
|
351
|
-
@name = name
|
|
352
|
-
@logger = logger
|
|
353
|
-
@timer_queue_provider = timer_queue_provider
|
|
354
|
-
@queue = SizedQueue.new(queue_size)
|
|
355
|
-
@active_count = 0
|
|
356
|
-
@abandoned_count = 0
|
|
357
|
-
@total_wait_ns = 0
|
|
358
|
-
@completed_count = 0
|
|
359
|
-
@mutex = Mutex.new
|
|
360
|
-
@shutdown = false
|
|
361
|
-
@workers = Array.new(pool_size) { |i| spawn_worker(i) }
|
|
362
|
-
end
|
|
363
|
-
|
|
364
|
-
# Submits a blocking operation to the pool.
|
|
365
|
-
# Returns a {PendingOperation} immediately after queue admission; the block runs
|
|
366
|
-
# on a worker thread.
|
|
367
|
-
#
|
|
368
|
-
# A submit-time +timeout+ is an operation-wide deadline measured from the start
|
|
369
|
-
# of this method, including queue wait. The timer settles the PendingOperation
|
|
370
|
-
# and notifies +on_complete+ without forcibly interrupting a running worker.
|
|
371
|
-
# If the deadline fires before worker execution starts, the block is skipped and
|
|
372
|
-
# the operation is not counted as abandoned. If it fires after execution starts,
|
|
373
|
-
# the operation is marked abandoned and the eventual worker result is discarded.
|
|
374
|
-
#
|
|
375
|
-
# Synchronous queue admission may still delay return from this method when
|
|
376
|
-
# +on_full: :wait+ is used; resolving that requires interruptible admission.
|
|
377
|
-
#
|
|
378
|
-
# @param timeout [Numeric, nil] operation-wide deadline in seconds
|
|
379
|
-
# @param cancellation_token [CancellationToken, nil]
|
|
380
|
-
# @param on_full [Symbol] +:wait+, +:raise+, or +:timeout+
|
|
381
|
-
# @param full_timeout [Numeric, nil] queue-admission timeout for +on_full: :timeout+
|
|
382
|
-
# @yield block containing the blocking call
|
|
383
|
-
# @return [PendingOperation]
|
|
384
|
-
# @raise [Phronomy::ConfigurationError] when +timeout+ is specified without a
|
|
385
|
-
# timer queue provider
|
|
386
|
-
# @raise [Phronomy::PoolShutdownError] when the pool has been shut down
|
|
387
|
-
# @raise [Phronomy::BackpressureError] when +on_full: :raise+ and queue is full
|
|
388
|
-
# @raise [Phronomy::TimeoutError] when +on_full: :timeout+ exceeds +full_timeout+
|
|
389
|
-
# @api private
|
|
390
|
-
def submit(timeout: nil, cancellation_token: nil, on_full: :wait, full_timeout: nil, &block)
|
|
391
|
-
raise Phronomy::PoolShutdownError, "pool has been shut down" if @shutdown
|
|
392
|
-
|
|
393
|
-
submitted_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
394
|
-
timer_queue = nil
|
|
395
|
-
if timeout
|
|
396
|
-
timer_queue = @timer_queue_provider&.call
|
|
397
|
-
unless timer_queue
|
|
398
|
-
raise Phronomy::ConfigurationError,
|
|
399
|
-
"timer_queue is required when submit timeout is specified"
|
|
400
|
-
end
|
|
401
|
-
end
|
|
402
|
-
|
|
403
|
-
op = PendingOperation.new(
|
|
404
|
-
block,
|
|
405
|
-
timeout: timeout,
|
|
406
|
-
cancellation_token: cancellation_token,
|
|
407
|
-
submitted_at: submitted_at,
|
|
408
|
-
on_abandoned: timeout ? -> { @mutex.synchronize { @abandoned_count += 1 } } : nil
|
|
409
|
-
)
|
|
410
|
-
|
|
411
|
-
begin
|
|
412
|
-
if timeout
|
|
413
|
-
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - submitted_at
|
|
414
|
-
remaining = timeout.to_f - elapsed
|
|
415
|
-
if remaining <= 0
|
|
416
|
-
op.fire_timeout!
|
|
417
|
-
return op
|
|
418
|
-
end
|
|
419
|
-
|
|
420
|
-
# Arm before queue admission so the deadline includes time spent waiting
|
|
421
|
-
# for a queue slot.
|
|
422
|
-
timer_queue.schedule(seconds: remaining) { op.fire_timeout! }
|
|
423
|
-
end
|
|
424
|
-
|
|
425
|
-
case on_full
|
|
426
|
-
when :raise
|
|
427
|
-
begin
|
|
428
|
-
@queue.push(op, true)
|
|
429
|
-
rescue ThreadError
|
|
430
|
-
raise Phronomy::BackpressureError,
|
|
431
|
-
"BlockingAdapterPool queue is full (depth: #{@queue_size})"
|
|
432
|
-
end
|
|
433
|
-
when :timeout
|
|
434
|
-
deadline = full_timeout ? (Process.clock_gettime(Process::CLOCK_MONOTONIC) + full_timeout) : nil
|
|
435
|
-
loop do
|
|
436
|
-
@queue.push(op, true)
|
|
437
|
-
break
|
|
438
|
-
rescue ThreadError
|
|
439
|
-
if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
440
|
-
raise Phronomy::TimeoutError,
|
|
441
|
-
"timed out waiting for a free slot in BlockingAdapterPool"
|
|
442
|
-
end
|
|
443
|
-
sleep(0.005)
|
|
444
|
-
end
|
|
445
|
-
else # :wait (default)
|
|
446
|
-
@queue.push(op)
|
|
447
|
-
end
|
|
448
|
-
rescue ClosedQueueError => e
|
|
449
|
-
# Shutdown raced with this submit — preserve the existing public error.
|
|
450
|
-
op.fail_submission!(e)
|
|
451
|
-
raise Phronomy::PoolShutdownError, "pool has been shut down"
|
|
452
|
-
rescue => e
|
|
453
|
-
op.fail_submission!(e)
|
|
454
|
-
raise
|
|
455
|
-
end
|
|
456
|
-
|
|
457
|
-
op
|
|
458
|
-
end
|
|
459
|
-
|
|
460
|
-
# Gracefully drains the pool and terminates all worker threads.
|
|
461
|
-
# Waits up to +drain_timeout+ seconds for in-flight operations to finish.
|
|
462
|
-
#
|
|
463
|
-
# Closing the underlying SizedQueue signals workers to exit after draining
|
|
464
|
-
# remaining items, without blocking on a full-queue push.
|
|
465
|
-
#
|
|
466
|
-
# @param drain_timeout [Numeric] seconds to wait for workers to finish
|
|
467
|
-
# @return [self]
|
|
468
|
-
# @api private
|
|
469
|
-
def shutdown(drain_timeout: 30)
|
|
470
|
-
@shutdown = true
|
|
471
|
-
@queue.close
|
|
472
|
-
@workers.each { |thread| thread.join(drain_timeout) }
|
|
473
|
-
self
|
|
474
|
-
end
|
|
475
|
-
|
|
476
|
-
# --- Metrics ----------------------------------------------------------
|
|
477
|
-
|
|
478
|
-
# @return [Integer] number of operations currently executing on workers
|
|
479
|
-
# @api private
|
|
480
|
-
def active_count
|
|
481
|
-
@mutex.synchronize { @active_count }
|
|
482
|
-
end
|
|
483
|
-
|
|
484
|
-
# @return [Integer] number of operations waiting in the queue
|
|
485
|
-
# @api private
|
|
486
|
-
def queue_depth
|
|
487
|
-
@queue.size
|
|
488
|
-
end
|
|
489
|
-
|
|
490
|
-
# @return [Integer] number of operations whose caller-facing timeout fired
|
|
491
|
-
# after worker execution had started
|
|
492
|
-
# @api private
|
|
493
|
-
def abandoned_count
|
|
494
|
-
@mutex.synchronize { @abandoned_count }
|
|
495
|
-
end
|
|
496
|
-
|
|
497
|
-
# Average time (in seconds) that completed or skipped operations spent in the
|
|
498
|
-
# queue waiting for a worker. Returns 0.0 when none have been processed yet.
|
|
499
|
-
# @return [Float]
|
|
500
|
-
# @api private
|
|
501
|
-
def average_wait_seconds
|
|
502
|
-
@mutex.synchronize do
|
|
503
|
-
return 0.0 if @completed_count.zero?
|
|
504
|
-
|
|
505
|
-
@total_wait_ns / @completed_count.to_f / 1_000_000_000.0
|
|
506
|
-
end
|
|
507
|
-
end
|
|
508
|
-
|
|
509
|
-
# @return [Integer] configured maximum number of worker threads
|
|
510
|
-
attr_reader :pool_size
|
|
511
|
-
|
|
512
|
-
# @return [Integer] configured maximum queue depth
|
|
513
|
-
attr_reader :queue_size
|
|
514
|
-
|
|
515
|
-
# @return [String, Symbol, nil] pool name used in thread labels
|
|
516
|
-
attr_reader :name
|
|
517
|
-
|
|
518
|
-
private
|
|
519
|
-
|
|
520
|
-
SENTINEL = :shutdown
|
|
521
|
-
private_constant :SENTINEL
|
|
522
|
-
|
|
523
|
-
def spawn_worker(index = nil)
|
|
524
|
-
label = ["phronomy", "blocking-pool", @name, index].compact.join("-")
|
|
525
|
-
Thread.new do
|
|
526
|
-
Thread.current.name = label
|
|
527
|
-
loop do
|
|
528
|
-
op = begin
|
|
529
|
-
@queue.pop
|
|
530
|
-
rescue ClosedQueueError
|
|
531
|
-
break
|
|
532
|
-
end
|
|
533
|
-
# nil is returned by a closed, empty Queue on some Ruby versions
|
|
534
|
-
break if op.nil? || op == SENTINEL
|
|
535
|
-
|
|
536
|
-
run_operation(op)
|
|
537
|
-
end
|
|
538
|
-
end
|
|
539
|
-
end
|
|
540
|
-
|
|
541
|
-
def run_operation(op)
|
|
542
|
-
@mutex.synchronize { @active_count += 1 }
|
|
543
|
-
|
|
544
|
-
begin
|
|
545
|
-
op.execute!
|
|
546
|
-
ensure
|
|
547
|
-
@mutex.synchronize do
|
|
548
|
-
@active_count -= 1
|
|
549
|
-
|
|
550
|
-
if op.abandoned?
|
|
551
|
-
@logger&.warn { "BlockingAdapterPool: worker finished operation after caller timed out" }
|
|
552
|
-
end
|
|
553
|
-
|
|
554
|
-
@total_wait_ns += (op.wait_time * 1_000_000_000).to_i
|
|
555
|
-
@completed_count += 1
|
|
556
|
-
end
|
|
557
|
-
end
|
|
558
|
-
end
|
|
559
|
-
end
|
|
560
|
-
end
|
|
561
|
-
end
|