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
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Concurrency
|
|
5
|
+
# A bounded, observable thread pool for synchronous work that must not run on
|
|
6
|
+
# the Runtime EventLoop.
|
|
7
|
+
#
|
|
8
|
+
# ## Architectural boundary
|
|
9
|
+
#
|
|
10
|
+
# `OffloadPool` is the bounded OS-thread execution boundary for synchronous
|
|
11
|
+
# operations that would otherwise occupy the EventLoop for too long. The work
|
|
12
|
+
# may be blocking I/O, CPU-bound Ruby processing, or another application-defined
|
|
13
|
+
# synchronous call. Phronomy deliberately does not classify the workload by
|
|
14
|
+
# cause; the application decides whether a unit of work is EventLoop-safe.
|
|
15
|
+
#
|
|
16
|
+
# Logical waits are different. Waiting for another Phronomy Task, Agent,
|
|
17
|
+
# Workflow, ToolInvocation, timer, or FSMSession must remain an explicit
|
|
18
|
+
# EventLoop/FSMSession continuation and must not consume an OffloadPool worker.
|
|
19
|
+
# See ADR-010.
|
|
20
|
+
#
|
|
21
|
+
# Submitted work is bounded so that:
|
|
22
|
+
#
|
|
23
|
+
# 1. The total number of worker OS threads is capped.
|
|
24
|
+
# 2. Queue depth is bounded (backpressure when the pool is saturated).
|
|
25
|
+
# 3. Per-operation timeouts and cancellation settle the caller-facing handle.
|
|
26
|
+
# 4. Operations that settle after worker execution has started are tracked as
|
|
27
|
+
# abandoned until that worker returns.
|
|
28
|
+
# 5. Metrics expose active work, queue depth, cumulative abandonment,
|
|
29
|
+
# currently-active abandonment, and average queue wait time.
|
|
30
|
+
#
|
|
31
|
+
# OffloadPool does not provide CPU isolation, CPU parallelism guarantees, or
|
|
32
|
+
# fairness between I/O and CPU-heavy work classes. Applications that need
|
|
33
|
+
# resource isolation may use named pools via {Runtime#pool}.
|
|
34
|
+
#
|
|
35
|
+
# @example Submitting synchronous work
|
|
36
|
+
# op = runtime.offload.submit(timeout: 30) { expensive_call }
|
|
37
|
+
# result = op.blocking_wait # blocks the calling thread until done
|
|
38
|
+
#
|
|
39
|
+
# @example With cancellation
|
|
40
|
+
# token = Phronomy::Concurrency::CancellationToken.timeout_after(60)
|
|
41
|
+
# op = pool.submit(timeout: 30, cancellation_token: token) { expensive_call }
|
|
42
|
+
# result = op.blocking_wait
|
|
43
|
+
class OffloadPool
|
|
44
|
+
# Represents the pending result of submitted offloaded work.
|
|
45
|
+
# Returned immediately by {OffloadPool#submit}; call {#blocking_wait}
|
|
46
|
+
# to synchronously wait from a non-EventLoop caller such as a low-level test.
|
|
47
|
+
class PendingOperation
|
|
48
|
+
# @return [Boolean] true when the caller-facing result has settled
|
|
49
|
+
# (success, failure, cancellation, or submit-time timeout)
|
|
50
|
+
# @api private
|
|
51
|
+
def done?
|
|
52
|
+
@mutex.synchronize { @done }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Boolean] true when the submit-time deadline settled the operation
|
|
56
|
+
# @api private
|
|
57
|
+
def timed_out?
|
|
58
|
+
@mutex.synchronize { @timed_out }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [Boolean] true when submit cancellation settled the operation
|
|
62
|
+
# @api private
|
|
63
|
+
def cancelled?
|
|
64
|
+
@mutex.synchronize { @cancelled }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Boolean] true when timeout/cancellation settled the caller-facing
|
|
68
|
+
# operation after worker execution had started. The worker is not forcibly
|
|
69
|
+
# interrupted and its eventual result is discarded.
|
|
70
|
+
# @api private
|
|
71
|
+
def abandoned?
|
|
72
|
+
@mutex.synchronize { @abandoned }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Float] seconds spent in the queue before execution started
|
|
76
|
+
# @api private
|
|
77
|
+
def wait_time
|
|
78
|
+
@wait_time || 0.0
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Blocks the calling thread until the operation settles and returns its value.
|
|
82
|
+
#
|
|
83
|
+
# A +timeout+ passed here is local to this synchronous waiter. When it expires,
|
|
84
|
+
# {Phronomy::TimeoutError} is raised to this caller, but the operation is not
|
|
85
|
+
# settled, marked abandoned, or otherwise changed. The worker continues, and
|
|
86
|
+
# another waiter or an +on_complete+ callback may receive the eventual result
|
|
87
|
+
# unless the submit-time deadline or submit cancellation settles the operation
|
|
88
|
+
# first.
|
|
89
|
+
#
|
|
90
|
+
# Operation-wide cancellation belongs exclusively to the
|
|
91
|
+
# +cancellation_token:+ passed to {OffloadPool#submit}. PendingOperation does
|
|
92
|
+
# not define a separate waiter-local cancellation-token lifecycle.
|
|
93
|
+
#
|
|
94
|
+
# @param timeout [Numeric, nil] maximum seconds this waiter will block
|
|
95
|
+
# @return [Object]
|
|
96
|
+
# @raise [Phronomy::TimeoutError]
|
|
97
|
+
# @raise [Exception] error that settled the submitted operation
|
|
98
|
+
# @api private
|
|
99
|
+
def blocking_wait(timeout: nil)
|
|
100
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout if timeout
|
|
101
|
+
value, error = @mutex.synchronize do
|
|
102
|
+
until @done
|
|
103
|
+
if deadline
|
|
104
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
105
|
+
if remaining <= 0
|
|
106
|
+
raise Phronomy::TimeoutError,
|
|
107
|
+
"timed out waiting for offloaded operation after #{timeout}s"
|
|
108
|
+
end
|
|
109
|
+
@cond.wait(@mutex, remaining)
|
|
110
|
+
else
|
|
111
|
+
@cond.wait(@mutex)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
[@value, @error]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
raise error if error
|
|
119
|
+
|
|
120
|
+
value
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Unified wait interface compatible with {Phronomy::Task#wait_result}.
|
|
124
|
+
alias_method :wait_result, :blocking_wait
|
|
125
|
+
|
|
126
|
+
# Registers an independent callback to be called when the operation settles.
|
|
127
|
+
#
|
|
128
|
+
# If the operation has already settled, the callback is invoked immediately
|
|
129
|
+
# on the calling thread. Otherwise it may be invoked on a pool worker thread,
|
|
130
|
+
# on the EventLoop thread when a timer fires, or on the thread that explicitly
|
|
131
|
+
# cancels the submit cancellation token. The execution thread is not
|
|
132
|
+
# guaranteed; callbacks must be thread-safe and should complete quickly.
|
|
133
|
+
#
|
|
134
|
+
# Completion callback failures are logged and isolated. One callback cannot
|
|
135
|
+
# suppress delivery to later callbacks or change the operation's settled
|
|
136
|
+
# result.
|
|
137
|
+
#
|
|
138
|
+
# The callback receives +result+ and +error+ (one of them will be +nil+).
|
|
139
|
+
#
|
|
140
|
+
# @yield [result, error]
|
|
141
|
+
# @return [self]
|
|
142
|
+
# @api private
|
|
143
|
+
def on_complete(&callback)
|
|
144
|
+
raise ArgumentError, "on_complete requires a block" unless callback
|
|
145
|
+
|
|
146
|
+
fire_args = nil
|
|
147
|
+
@mutex.synchronize do
|
|
148
|
+
if @done
|
|
149
|
+
fire_args = [@value, @error]
|
|
150
|
+
else
|
|
151
|
+
@callbacks ||= []
|
|
152
|
+
@callbacks << callback
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
deliver_completion_callback(callback, *fire_args) if fire_args
|
|
156
|
+
self
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @api private
|
|
160
|
+
def initialize(
|
|
161
|
+
block,
|
|
162
|
+
timeout: nil,
|
|
163
|
+
cancellation_token: nil,
|
|
164
|
+
on_abandoned: nil,
|
|
165
|
+
submitted_at: nil
|
|
166
|
+
)
|
|
167
|
+
@block = block
|
|
168
|
+
@timeout = timeout
|
|
169
|
+
@cancellation_token = cancellation_token
|
|
170
|
+
@on_abandoned = on_abandoned
|
|
171
|
+
@value = nil
|
|
172
|
+
@error = nil
|
|
173
|
+
@done = false
|
|
174
|
+
@timed_out = false
|
|
175
|
+
@cancelled = false
|
|
176
|
+
@started = false
|
|
177
|
+
@abandoned = false
|
|
178
|
+
@wait_time = nil
|
|
179
|
+
@submitted_at = submitted_at ||
|
|
180
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
181
|
+
@mutex = Mutex.new
|
|
182
|
+
@cond = ConditionVariable.new
|
|
183
|
+
|
|
184
|
+
# Explicit submit cancellation is operation-wide. Deadline-only tokens are
|
|
185
|
+
# promoted to cancel! by OffloadPool#submit using the Runtime timer queue.
|
|
186
|
+
@cancellation_callback = if @cancellation_token
|
|
187
|
+
-> { fire_cancellation! }
|
|
188
|
+
end
|
|
189
|
+
@cancellation_token&.on_cancel(&@cancellation_callback)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Settles the operation with a submit-time timeout.
|
|
193
|
+
#
|
|
194
|
+
# The worker is not interrupted. If execution has already started, the
|
|
195
|
+
# operation is marked abandoned and the worker's eventual result is discarded.
|
|
196
|
+
#
|
|
197
|
+
# @return [Boolean] true when this call settled the operation, false when the
|
|
198
|
+
# operation had already settled
|
|
199
|
+
# @api private
|
|
200
|
+
def fire_timeout!
|
|
201
|
+
settle_early!(timed_out: true) do
|
|
202
|
+
Phronomy::TimeoutError.new(
|
|
203
|
+
"offloaded operation timed out after #{@timeout}s"
|
|
204
|
+
)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Settles the operation because its submit cancellation token was cancelled.
|
|
209
|
+
#
|
|
210
|
+
# Cancellation is caller-facing settlement, not asynchronous worker
|
|
211
|
+
# interruption. If execution has already started, the operation is marked
|
|
212
|
+
# abandoned and the worker continues until the synchronous call returns.
|
|
213
|
+
#
|
|
214
|
+
# @return [Boolean] true when this call settled the operation
|
|
215
|
+
# @api private
|
|
216
|
+
def fire_cancellation!
|
|
217
|
+
settle_early!(cancelled: true) do |started|
|
|
218
|
+
message = if started
|
|
219
|
+
"offloaded operation cancelled during execution"
|
|
220
|
+
else
|
|
221
|
+
"offloaded operation cancelled before execution"
|
|
222
|
+
end
|
|
223
|
+
CancellationError.new(message)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Marks an operation that could not be admitted to the pool as settled, so a
|
|
228
|
+
# previously armed submit-time timer becomes a harmless no-op.
|
|
229
|
+
#
|
|
230
|
+
# @param error [Exception, nil]
|
|
231
|
+
# @return [Boolean] true when this call changed the state
|
|
232
|
+
# @api private
|
|
233
|
+
def fail_submission!(error = nil)
|
|
234
|
+
callbacks = nil
|
|
235
|
+
changed = @mutex.synchronize do
|
|
236
|
+
next false if @done
|
|
237
|
+
|
|
238
|
+
@done = true
|
|
239
|
+
@error = error if error
|
|
240
|
+
@cond.broadcast
|
|
241
|
+
callbacks = @callbacks
|
|
242
|
+
@callbacks = nil
|
|
243
|
+
true
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
detach_submit_cancellation if changed
|
|
247
|
+
deliver_completion_callbacks(callbacks, nil, error) if changed
|
|
248
|
+
changed
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Executes the operation on a pool worker.
|
|
252
|
+
# @api private
|
|
253
|
+
def execute!
|
|
254
|
+
@wait_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @submitted_at
|
|
255
|
+
|
|
256
|
+
# A monotonic deadline can make cancelled? true before the timer callback
|
|
257
|
+
# runs. Promote that state to explicit cancellation so callbacks and all
|
|
258
|
+
# observers see the same operation-wide settlement.
|
|
259
|
+
if @cancellation_token&.cancelled?
|
|
260
|
+
@cancellation_token.cancel!
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
should_run = @mutex.synchronize do
|
|
264
|
+
if @done
|
|
265
|
+
false
|
|
266
|
+
else
|
|
267
|
+
# Linearization point: after this assignment, a concurrent timeout or
|
|
268
|
+
# cancellation is classified as in-flight abandonment and the block
|
|
269
|
+
# itself is allowed to finish without Thread#raise.
|
|
270
|
+
@started = true
|
|
271
|
+
true
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
return unless should_run
|
|
275
|
+
|
|
276
|
+
# Do NOT use Timeout.timeout here — it delivers an async Thread#raise
|
|
277
|
+
# that can corrupt library/application state (mutexes, C extensions, etc.).
|
|
278
|
+
# I/O libraries should set native connection/read timeouts. CPU-heavy work
|
|
279
|
+
# that needs hard termination should use a future process-offload facility.
|
|
280
|
+
begin
|
|
281
|
+
complete_with_value!(@block.call)
|
|
282
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
283
|
+
# Rescue all Exception subclasses so non-StandardError raises still
|
|
284
|
+
# settle the operation and unblock waiters.
|
|
285
|
+
complete_with_error!(e)
|
|
286
|
+
raise if e.is_a?(SignalException) || e.is_a?(SystemExit)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
private
|
|
291
|
+
|
|
292
|
+
def settle_early!(timed_out: false, cancelled: false)
|
|
293
|
+
callbacks = nil
|
|
294
|
+
abandoned_now = false
|
|
295
|
+
error = nil
|
|
296
|
+
|
|
297
|
+
changed = @mutex.synchronize do
|
|
298
|
+
next false if @done
|
|
299
|
+
|
|
300
|
+
# The error and @started classification are decided under the same lock
|
|
301
|
+
# as settlement. This is the cancellation/worker-start linearization
|
|
302
|
+
# point: cancellation that wins here prevents execution; worker start
|
|
303
|
+
# that wins first produces an abandoned in-flight operation.
|
|
304
|
+
error = yield(@started)
|
|
305
|
+
@done = true
|
|
306
|
+
@timed_out = timed_out
|
|
307
|
+
@cancelled = cancelled
|
|
308
|
+
@error = error
|
|
309
|
+
@abandoned = @started
|
|
310
|
+
abandoned_now = @abandoned
|
|
311
|
+
@cond.broadcast
|
|
312
|
+
callbacks = @callbacks
|
|
313
|
+
@callbacks = nil
|
|
314
|
+
true
|
|
315
|
+
end
|
|
316
|
+
return false unless changed
|
|
317
|
+
|
|
318
|
+
detach_submit_cancellation
|
|
319
|
+
notify_abandoned if abandoned_now
|
|
320
|
+
deliver_completion_callbacks(callbacks, nil, error)
|
|
321
|
+
true
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def notify_abandoned
|
|
325
|
+
@on_abandoned&.call(self)
|
|
326
|
+
rescue => error
|
|
327
|
+
Phronomy.configuration.logger&.error do
|
|
328
|
+
"OffloadPool abandoned callback failed: #{error.class}: #{error.message}"
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def complete_with_value!(value)
|
|
333
|
+
callbacks = nil
|
|
334
|
+
changed = @mutex.synchronize do
|
|
335
|
+
next false if @done
|
|
336
|
+
|
|
337
|
+
@value = value
|
|
338
|
+
@done = true
|
|
339
|
+
@cond.broadcast
|
|
340
|
+
callbacks = @callbacks
|
|
341
|
+
@callbacks = nil
|
|
342
|
+
true
|
|
343
|
+
end
|
|
344
|
+
detach_submit_cancellation if changed
|
|
345
|
+
deliver_completion_callbacks(callbacks, value, nil) if changed
|
|
346
|
+
changed
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def complete_with_error!(error)
|
|
350
|
+
callbacks = nil
|
|
351
|
+
changed = @mutex.synchronize do
|
|
352
|
+
next false if @done
|
|
353
|
+
|
|
354
|
+
@error = error
|
|
355
|
+
@done = true
|
|
356
|
+
@cond.broadcast
|
|
357
|
+
callbacks = @callbacks
|
|
358
|
+
@callbacks = nil
|
|
359
|
+
true
|
|
360
|
+
end
|
|
361
|
+
detach_submit_cancellation if changed
|
|
362
|
+
deliver_completion_callbacks(callbacks, nil, error) if changed
|
|
363
|
+
changed
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def deliver_completion_callbacks(callbacks, value, error)
|
|
367
|
+
callbacks&.each do |callback|
|
|
368
|
+
deliver_completion_callback(callback, value, error)
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def deliver_completion_callback(callback, value, error)
|
|
373
|
+
callback.call(value, error)
|
|
374
|
+
rescue => callback_error
|
|
375
|
+
Phronomy.configuration.logger&.error do
|
|
376
|
+
"[OffloadPool::PendingOperation] on_complete callback raised " \
|
|
377
|
+
"#{callback_error.class}: #{callback_error.message}"
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def detach_submit_cancellation
|
|
382
|
+
return unless @cancellation_token && @cancellation_callback
|
|
383
|
+
|
|
384
|
+
@cancellation_token.send(
|
|
385
|
+
:unregister_cancel_callback,
|
|
386
|
+
@cancellation_callback
|
|
387
|
+
)
|
|
388
|
+
@cancellation_callback = nil
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# @param pool_size [Integer] maximum number of worker threads
|
|
393
|
+
# @param queue_size [Integer] maximum pending operations waiting for a worker
|
|
394
|
+
# @param name [String, Symbol, nil] optional pool name used in thread labels
|
|
395
|
+
# @param logger [Logger, nil] optional logger for warnings
|
|
396
|
+
# @param timer_queue_provider [#call, nil] returns a TimerQueue-compatible
|
|
397
|
+
# object. Required when +submit(timeout:)+ or a monotonic-deadline
|
|
398
|
+
# cancellation token is used.
|
|
399
|
+
# @api private
|
|
400
|
+
def initialize(
|
|
401
|
+
pool_size: 10,
|
|
402
|
+
queue_size: 100,
|
|
403
|
+
name: nil,
|
|
404
|
+
logger: nil,
|
|
405
|
+
timer_queue_provider: nil
|
|
406
|
+
)
|
|
407
|
+
@pool_size = pool_size
|
|
408
|
+
@queue_size = queue_size
|
|
409
|
+
@name = name
|
|
410
|
+
@logger = logger
|
|
411
|
+
@timer_queue_provider = timer_queue_provider
|
|
412
|
+
@queue = SizedQueue.new(queue_size)
|
|
413
|
+
@active_count = 0
|
|
414
|
+
@abandoned_count = 0
|
|
415
|
+
@running_operation_ids = {}
|
|
416
|
+
@abandoned_active_operation_ids = {}
|
|
417
|
+
@total_wait_ns = 0
|
|
418
|
+
@completed_count = 0
|
|
419
|
+
@mutex = Mutex.new
|
|
420
|
+
@shutdown = false
|
|
421
|
+
@workers = Array.new(pool_size) { |i| spawn_worker(i) }
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Submits synchronous off-EventLoop work to the pool.
|
|
425
|
+
# Returns a {PendingOperation} immediately after queue admission; the block runs
|
|
426
|
+
# on a worker thread. Do not submit logical waits (for example waiting for a
|
|
427
|
+
# child Agent Task) merely to make them asynchronous; those belong to
|
|
428
|
+
# FSMSession/EventLoop completion events.
|
|
429
|
+
#
|
|
430
|
+
# A submit-time +timeout+ is an operation-wide deadline measured from the start
|
|
431
|
+
# of this method, including queue wait. The timer settles the PendingOperation
|
|
432
|
+
# and notifies +on_complete+ without forcibly interrupting a running worker.
|
|
433
|
+
# If the deadline fires before worker execution starts, the block is skipped.
|
|
434
|
+
# If it fires after execution starts, the operation is marked abandoned and the
|
|
435
|
+
# eventual worker result is discarded.
|
|
436
|
+
#
|
|
437
|
+
# The submit +cancellation_token+ is also operation-wide. Explicit cancellation
|
|
438
|
+
# settles the PendingOperation immediately. A token with a monotonic deadline is
|
|
439
|
+
# attached to the Runtime timer queue so deadline expiry becomes explicit
|
|
440
|
+
# cancellation without adding a polling Thread. Cancellation before execution
|
|
441
|
+
# skips the block; cancellation after execution starts abandons only the
|
|
442
|
+
# caller-facing result and never uses Thread#raise.
|
|
443
|
+
#
|
|
444
|
+
# Synchronous queue admission may delay return from this method when
|
|
445
|
+
# +on_full: :wait+ is used. EventLoop-owned framework paths therefore submit
|
|
446
|
+
# with +on_full: :raise+ and handle backpressure asynchronously.
|
|
447
|
+
#
|
|
448
|
+
# @param timeout [Numeric, nil] operation-wide deadline in seconds
|
|
449
|
+
# @param cancellation_token [CancellationToken, nil] operation-wide token
|
|
450
|
+
# @param on_full [Symbol] +:wait+, +:raise+, or +:timeout+
|
|
451
|
+
# @param full_timeout [Numeric, nil] queue-admission timeout for +on_full: :timeout+
|
|
452
|
+
# @yield block containing synchronous work
|
|
453
|
+
# @return [PendingOperation]
|
|
454
|
+
# @raise [Phronomy::ConfigurationError] when a timer is required but no
|
|
455
|
+
# timer queue provider is configured
|
|
456
|
+
# @raise [Phronomy::PoolShutdownError] when the pool has been shut down
|
|
457
|
+
# @raise [Phronomy::BackpressureError] when +on_full: :raise+ and queue is full
|
|
458
|
+
# @raise [Phronomy::TimeoutError] when +on_full: :timeout+ exceeds +full_timeout+
|
|
459
|
+
# @api private
|
|
460
|
+
def submit(
|
|
461
|
+
timeout: nil,
|
|
462
|
+
cancellation_token: nil,
|
|
463
|
+
on_full: :wait,
|
|
464
|
+
full_timeout: nil,
|
|
465
|
+
&block
|
|
466
|
+
)
|
|
467
|
+
raise Phronomy::PoolShutdownError, "pool has been shut down" if @shutdown
|
|
468
|
+
|
|
469
|
+
submitted_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
470
|
+
already_cancelled = cancellation_token&.cancelled? || false
|
|
471
|
+
cancellation_remaining = if already_cancelled
|
|
472
|
+
nil
|
|
473
|
+
else
|
|
474
|
+
cancellation_token&.remaining_monotonic_seconds
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
needs_timer = !timeout.nil? ||
|
|
478
|
+
(!cancellation_remaining.nil? && cancellation_remaining > 0)
|
|
479
|
+
timer_queue = @timer_queue_provider&.call if needs_timer
|
|
480
|
+
if needs_timer && !timer_queue
|
|
481
|
+
raise Phronomy::ConfigurationError,
|
|
482
|
+
"timer_queue is required when submit timeout or cancellation deadline is specified"
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
op = PendingOperation.new(
|
|
486
|
+
block,
|
|
487
|
+
timeout: timeout,
|
|
488
|
+
cancellation_token: cancellation_token,
|
|
489
|
+
submitted_at: submitted_at,
|
|
490
|
+
on_abandoned: method(:record_abandoned)
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
# on_cancel only reacts to explicit cancel!, whereas cancelled? also covers a
|
|
494
|
+
# monotonic deadline. Promote an already-expired deadline immediately.
|
|
495
|
+
if already_cancelled
|
|
496
|
+
cancellation_token.cancel!
|
|
497
|
+
return op
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
begin
|
|
501
|
+
if timeout
|
|
502
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - submitted_at
|
|
503
|
+
remaining = timeout.to_f - elapsed
|
|
504
|
+
if remaining <= 0
|
|
505
|
+
op.fire_timeout!
|
|
506
|
+
return op
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Arm before queue admission so the deadline includes time spent waiting
|
|
510
|
+
# for a queue slot.
|
|
511
|
+
timer_queue.schedule(seconds: remaining) { op.fire_timeout! }
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
if cancellation_remaining
|
|
515
|
+
# Re-read after timeout setup so the scheduled delay reflects setup time.
|
|
516
|
+
remaining = cancellation_token.remaining_monotonic_seconds
|
|
517
|
+
if remaining <= 0
|
|
518
|
+
cancellation_token.cancel!
|
|
519
|
+
return op
|
|
520
|
+
end
|
|
521
|
+
timer_queue.schedule(seconds: remaining) { cancellation_token.cancel! }
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# Cancellation/timeout can race with timer registration. Do not enqueue
|
|
525
|
+
# already-settled work when the race is observable here.
|
|
526
|
+
return op if op.done?
|
|
527
|
+
|
|
528
|
+
case on_full
|
|
529
|
+
when :raise
|
|
530
|
+
begin
|
|
531
|
+
@queue.push(op, true)
|
|
532
|
+
rescue ThreadError
|
|
533
|
+
raise Phronomy::BackpressureError,
|
|
534
|
+
"OffloadPool queue is full (depth: #{@queue_size})"
|
|
535
|
+
end
|
|
536
|
+
when :timeout
|
|
537
|
+
deadline = full_timeout ?
|
|
538
|
+
(Process.clock_gettime(Process::CLOCK_MONOTONIC) + full_timeout) :
|
|
539
|
+
nil
|
|
540
|
+
loop do
|
|
541
|
+
return op if op.done?
|
|
542
|
+
|
|
543
|
+
@queue.push(op, true)
|
|
544
|
+
break
|
|
545
|
+
rescue ThreadError
|
|
546
|
+
if deadline &&
|
|
547
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
548
|
+
raise Phronomy::TimeoutError,
|
|
549
|
+
"timed out waiting for a free slot in OffloadPool"
|
|
550
|
+
end
|
|
551
|
+
sleep(0.005)
|
|
552
|
+
end
|
|
553
|
+
else # :wait (default)
|
|
554
|
+
@queue.push(op)
|
|
555
|
+
end
|
|
556
|
+
rescue ClosedQueueError => e
|
|
557
|
+
# Shutdown raced with this submit — preserve the existing public error.
|
|
558
|
+
op.fail_submission!(e)
|
|
559
|
+
raise Phronomy::PoolShutdownError, "pool has been shut down"
|
|
560
|
+
rescue => e
|
|
561
|
+
op.fail_submission!(e)
|
|
562
|
+
raise
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
op
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
# Gracefully drains the pool and terminates all worker threads.
|
|
569
|
+
# Waits up to +drain_timeout+ seconds for in-flight operations to finish.
|
|
570
|
+
#
|
|
571
|
+
# Closing the underlying SizedQueue signals workers to exit after draining
|
|
572
|
+
# remaining items, without blocking on a full-queue push.
|
|
573
|
+
#
|
|
574
|
+
# @param drain_timeout [Numeric] seconds to wait for workers to finish
|
|
575
|
+
# @return [self]
|
|
576
|
+
# @api private
|
|
577
|
+
def shutdown(drain_timeout: 30)
|
|
578
|
+
@shutdown = true
|
|
579
|
+
@queue.close
|
|
580
|
+
@workers.each { |thread| thread.join(drain_timeout) }
|
|
581
|
+
self
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# --- Metrics ----------------------------------------------------------
|
|
585
|
+
|
|
586
|
+
# @return [Integer] number of operations currently executing on workers
|
|
587
|
+
# @api private
|
|
588
|
+
def active_count
|
|
589
|
+
@mutex.synchronize { @active_count }
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
# @return [Integer] number of operations waiting in the queue
|
|
593
|
+
# @api private
|
|
594
|
+
def queue_depth
|
|
595
|
+
@queue.size
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
# @return [Integer] cumulative number of operations whose caller-facing timeout
|
|
599
|
+
# or cancellation settled after worker execution had started
|
|
600
|
+
# @api private
|
|
601
|
+
def abandoned_count
|
|
602
|
+
@mutex.synchronize { @abandoned_count }
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
# @return [Integer] number of abandoned operations that still occupy worker
|
|
606
|
+
# capacity at this instant
|
|
607
|
+
# @api private
|
|
608
|
+
def abandoned_active_count
|
|
609
|
+
@mutex.synchronize { @abandoned_active_operation_ids.size }
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
# Average time (in seconds) that completed or skipped operations spent in the
|
|
613
|
+
# queue waiting for a worker. Returns 0.0 when none have been processed yet.
|
|
614
|
+
# @return [Float]
|
|
615
|
+
# @api private
|
|
616
|
+
def average_wait_seconds
|
|
617
|
+
@mutex.synchronize do
|
|
618
|
+
return 0.0 if @completed_count.zero?
|
|
619
|
+
|
|
620
|
+
@total_wait_ns / @completed_count.to_f / 1_000_000_000.0
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
# @return [Integer] configured maximum number of worker threads
|
|
625
|
+
attr_reader :pool_size
|
|
626
|
+
|
|
627
|
+
# @return [Integer] configured maximum queue depth
|
|
628
|
+
attr_reader :queue_size
|
|
629
|
+
|
|
630
|
+
# @return [String, Symbol, nil] pool name used in thread labels
|
|
631
|
+
attr_reader :name
|
|
632
|
+
|
|
633
|
+
private
|
|
634
|
+
|
|
635
|
+
SENTINEL = :shutdown
|
|
636
|
+
private_constant :SENTINEL
|
|
637
|
+
|
|
638
|
+
def spawn_worker(index = nil)
|
|
639
|
+
label = ["phronomy", "offload-pool", @name, index].compact.join("-")
|
|
640
|
+
Thread.new do
|
|
641
|
+
Thread.current.name = label
|
|
642
|
+
loop do
|
|
643
|
+
op = begin
|
|
644
|
+
@queue.pop
|
|
645
|
+
rescue ClosedQueueError
|
|
646
|
+
break
|
|
647
|
+
end
|
|
648
|
+
# nil is returned by a closed, empty Queue on some Ruby versions
|
|
649
|
+
break if op.nil? || op == SENTINEL
|
|
650
|
+
|
|
651
|
+
run_operation(op)
|
|
652
|
+
end
|
|
653
|
+
end
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
def record_abandoned(operation)
|
|
657
|
+
operation_id = operation.object_id
|
|
658
|
+
@mutex.synchronize do
|
|
659
|
+
@abandoned_count += 1
|
|
660
|
+
if @running_operation_ids.key?(operation_id)
|
|
661
|
+
@abandoned_active_operation_ids[operation_id] = true
|
|
662
|
+
end
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
def run_operation(op)
|
|
667
|
+
operation_id = op.object_id
|
|
668
|
+
@mutex.synchronize do
|
|
669
|
+
@active_count += 1
|
|
670
|
+
@running_operation_ids[operation_id] = true
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
begin
|
|
674
|
+
op.execute!
|
|
675
|
+
ensure
|
|
676
|
+
abandoned = op.abandoned?
|
|
677
|
+
wait_ns = (op.wait_time * 1_000_000_000).to_i
|
|
678
|
+
|
|
679
|
+
@mutex.synchronize do
|
|
680
|
+
@active_count -= 1
|
|
681
|
+
@running_operation_ids.delete(operation_id)
|
|
682
|
+
@abandoned_active_operation_ids.delete(operation_id)
|
|
683
|
+
@total_wait_ns += wait_ns
|
|
684
|
+
@completed_count += 1
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
if abandoned
|
|
688
|
+
@logger&.warn do
|
|
689
|
+
"OffloadPool: worker finished after caller-facing timeout/cancellation settlement"
|
|
690
|
+
end
|
|
691
|
+
end
|
|
692
|
+
end
|
|
693
|
+
end
|
|
694
|
+
end
|
|
695
|
+
end
|
|
696
|
+
end
|