phronomy 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +90 -0
- data/README.md +51 -2
- data/docs/mcp-client.md +75 -0
- data/gemfiles/mcp_1_0.gemfile +9 -0
- data/lib/phronomy/agent/base.rb +17 -17
- data/lib/phronomy/agent/context/capability/base.rb +6 -0
- data/lib/phronomy/agent/invocation_session.rb +10 -4
- data/lib/phronomy/agent/phase_machine_builder.rb +5 -4
- data/lib/phronomy/configuration.rb +15 -46
- data/lib/phronomy/diagnostics.rb +1 -1
- data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +230 -118
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +5 -1
- data/lib/phronomy/engine/concurrency/pool_registry.rb +8 -3
- data/lib/phronomy/engine/event_loop.rb +319 -272
- data/lib/phronomy/engine/fsm_session.rb +17 -14
- data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +1 -1
- data/lib/phronomy/engine/runtime/shutdown_result.rb +62 -0
- data/lib/phronomy/engine/runtime/task_registry.rb +62 -15
- data/lib/phronomy/engine/runtime.rb +247 -57
- data/lib/phronomy/metrics.rb +4 -3
- data/lib/phronomy/testing/scheduler_helpers.rb +12 -3
- data/lib/phronomy/tools/mcp.rb +385 -81
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/phase_machine_builder.rb +13 -9
- data/lib/phronomy/workflow_context.rb +1 -2
- data/lib/phronomy/workflow_runner.rb +22 -12
- data/lib/phronomy.rb +24 -19
- metadata +47 -6
- data/lib/phronomy/engine/concurrency/concurrency_gate.rb +0 -157
- data/lib/phronomy/engine/concurrency/gate_registry.rb +0 -51
|
@@ -34,19 +34,27 @@ module Phronomy
|
|
|
34
34
|
# result = op.blocking_wait
|
|
35
35
|
class BlockingAdapterPool
|
|
36
36
|
# Represents the pending result of a submitted blocking operation.
|
|
37
|
-
# Returned immediately by {BlockingAdapterPool#submit}; call {#
|
|
38
|
-
# wait for the result.
|
|
37
|
+
# Returned immediately by {BlockingAdapterPool#submit}; call {#blocking_wait}
|
|
38
|
+
# to wait for the result.
|
|
39
39
|
class PendingOperation
|
|
40
|
-
# @return [Boolean] true when the
|
|
40
|
+
# @return [Boolean] true when the caller-facing result has settled
|
|
41
|
+
# (success, failure, cancellation, or submit-time timeout)
|
|
41
42
|
# @api private
|
|
42
43
|
def done?
|
|
43
44
|
@mutex.synchronize { @done }
|
|
44
45
|
end
|
|
45
46
|
|
|
46
|
-
# @return [Boolean] true when the
|
|
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.
|
|
47
55
|
# @api private
|
|
48
56
|
def abandoned?
|
|
49
|
-
@abandoned
|
|
57
|
+
@mutex.synchronize { @abandoned }
|
|
50
58
|
end
|
|
51
59
|
|
|
52
60
|
# @return [Float] seconds spent in the queue before execution started
|
|
@@ -57,45 +65,28 @@ module Phronomy
|
|
|
57
65
|
|
|
58
66
|
# Blocks until the operation completes and returns its value.
|
|
59
67
|
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
#
|
|
63
|
-
#
|
|
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.
|
|
64
77
|
#
|
|
65
78
|
# An optional +cancellation_token+ may be passed here (or at submit time).
|
|
66
79
|
# If the token is cancelled while waiting, {Phronomy::CancellationError} is
|
|
67
|
-
# raised
|
|
80
|
+
# raised without interrupting the worker.
|
|
68
81
|
#
|
|
69
82
|
# **Cooperative path (`:fiber` / `DeterministicScheduler`):**
|
|
70
|
-
# When called from a Fiber managed by {DeterministicScheduler}
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
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.
|
|
75
88
|
#
|
|
76
|
-
# @
|
|
77
|
-
# Phronomy uses a non-preemptive, cooperative-first concurrency model.
|
|
78
|
-
# Cancellation is *cooperative*, not preemptive:
|
|
79
|
-
# - When a +cancellation_token+ is cancelled, +CancellationError+ is
|
|
80
|
-
# raised to the +await+ caller immediately; when the timeout fires,
|
|
81
|
-
# +TimeoutError+ is raised instead. In both cases, the underlying
|
|
82
|
-
# worker thread is **not** forcibly stopped.
|
|
83
|
-
# - The worker thread will complete its submitted block naturally.
|
|
84
|
-
# Code inside the block must call +token.check!+ at suitable
|
|
85
|
-
# checkpoints to observe the cancelled state and exit early.
|
|
86
|
-
# - There is no +Thread#kill+ or +Thread#raise+ involved. The framework
|
|
87
|
-
# never forcibly terminates worker threads.
|
|
88
|
-
#
|
|
89
|
-
# @note **Cooperative timeout limitation**: the +timeout:+ parameter passed
|
|
90
|
-
# to +await+ is *not* enforced on the cooperative path. The calling Fiber
|
|
91
|
-
# remains suspended until the worker thread finishes regardless of how many
|
|
92
|
-
# seconds elapse. This is because the cooperative scheduler cannot
|
|
93
|
-
# preempt a running OS thread. If a time bound is required, set
|
|
94
|
-
# +timeout:+ at {BlockingAdapterPool#submit submit} time instead; the pool
|
|
95
|
-
# will then abandon the operation on the worker side and mark it as
|
|
96
|
-
# {#abandoned?}.
|
|
97
|
-
#
|
|
98
|
-
# @param timeout [Numeric, nil] seconds from now before raising TimeoutError
|
|
89
|
+
# @param timeout [Numeric, nil] maximum seconds this waiter will block
|
|
99
90
|
# (thread path only; ignored on the cooperative/fiber path)
|
|
100
91
|
# @param cancellation_token [CancellationToken, nil]
|
|
101
92
|
# @return [Object]
|
|
@@ -104,86 +95,71 @@ module Phronomy
|
|
|
104
95
|
# @raise [Exception] error raised inside the submitted block
|
|
105
96
|
# @api private
|
|
106
97
|
def blocking_wait(timeout: nil, cancellation_token: nil)
|
|
107
|
-
effective_timeout = [timeout, @timeout].compact.min
|
|
108
98
|
effective_token = cancellation_token || @cancellation_token
|
|
109
99
|
|
|
110
100
|
raise CancellationError, "blocking operation cancelled" if effective_token&.cancelled?
|
|
111
101
|
|
|
112
102
|
# Cooperative context: suspend the calling Fiber rather than blocking
|
|
113
103
|
# the OS thread so that DeterministicScheduler can continue dispatching
|
|
114
|
-
# other tasks while waiting for the blocking worker
|
|
115
|
-
# (Issue #338, ADR-010 Rule 3)
|
|
116
|
-
# Uses the same thread-local key as Task::FiberBackend::SCHEDULER_KEY
|
|
117
|
-
# (:phronomy_deterministic_scheduler) to avoid a cross-file constant
|
|
118
|
-
# dependency at load time.
|
|
104
|
+
# other tasks while waiting for the blocking worker or submit-time timer.
|
|
119
105
|
scheduler = Thread.current.thread_variable_get(:phronomy_deterministic_scheduler)
|
|
120
106
|
in_managed_fiber = !Fiber.respond_to?(:main) || Fiber.current != Fiber.main
|
|
121
107
|
if scheduler && in_managed_fiber
|
|
122
|
-
unless
|
|
123
|
-
# Register this await with the scheduler so run_until_idle knows
|
|
124
|
-
# not to exit until the worker thread completes (Issue #338).
|
|
108
|
+
unless done?
|
|
125
109
|
scheduler.track_blocking_await
|
|
126
110
|
waiting_fiber = Fiber.current
|
|
127
111
|
on_complete do |_result, _error|
|
|
128
|
-
# Decrement the counter and wake run_until_idle, then re-enqueue
|
|
129
|
-
# the suspended Fiber for cooperative resumption.
|
|
130
112
|
scheduler.complete_blocking_await
|
|
131
113
|
scheduler.enqueue_fiber(-> { waiting_fiber.resume })
|
|
132
114
|
end
|
|
133
115
|
Fiber.yield(:cooperative_suspend)
|
|
134
116
|
end
|
|
117
|
+
|
|
135
118
|
raise CancellationError, "blocking operation cancelled" if effective_token&.cancelled?
|
|
136
|
-
raise @error if @error
|
|
137
119
|
|
|
138
|
-
|
|
120
|
+
value, error = @mutex.synchronize { [@value, @error] }
|
|
121
|
+
raise error if error
|
|
122
|
+
|
|
123
|
+
return value
|
|
139
124
|
end
|
|
140
125
|
|
|
141
126
|
# Wake up the waiting thread whenever the token is cancelled so we can
|
|
142
|
-
# propagate cancellation without sleeping until the
|
|
127
|
+
# propagate cancellation without sleeping until the operation completes.
|
|
143
128
|
effective_token&.on_cancel { @mutex.synchronize { @cond.broadcast } }
|
|
144
129
|
|
|
145
|
-
if
|
|
146
|
-
|
|
147
|
-
@
|
|
148
|
-
|
|
149
|
-
raise CancellationError, "blocking operation cancelled" if effective_token&.cancelled?
|
|
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?
|
|
150
134
|
|
|
135
|
+
if deadline
|
|
151
136
|
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
152
137
|
if remaining <= 0
|
|
153
|
-
|
|
154
|
-
unless @abandoned
|
|
155
|
-
@abandoned = true
|
|
156
|
-
@on_abandoned&.call
|
|
157
|
-
end
|
|
158
|
-
raise Phronomy::TimeoutError, "blocking operation timed out after #{effective_timeout}s"
|
|
138
|
+
raise Phronomy::TimeoutError, "timed out waiting for blocking operation after #{timeout}s"
|
|
159
139
|
end
|
|
160
140
|
@cond.wait(@mutex, remaining)
|
|
161
|
-
|
|
162
|
-
end
|
|
163
|
-
else
|
|
164
|
-
@mutex.synchronize do
|
|
165
|
-
until @done
|
|
166
|
-
raise CancellationError, "blocking operation cancelled" if effective_token&.cancelled?
|
|
167
|
-
|
|
141
|
+
else
|
|
168
142
|
@cond.wait(@mutex)
|
|
169
143
|
end
|
|
170
144
|
end
|
|
145
|
+
|
|
146
|
+
[@value, @error]
|
|
171
147
|
end
|
|
172
|
-
raise @error if @error
|
|
173
148
|
|
|
174
|
-
|
|
149
|
+
raise error if error
|
|
150
|
+
|
|
151
|
+
value
|
|
175
152
|
end
|
|
176
153
|
|
|
177
154
|
# Unified wait interface compatible with {Phronomy::Task#wait_result}.
|
|
178
|
-
# Delegates to {#blocking_wait} so that callers treating the return value
|
|
179
|
-
# of {ToolExecutor.call_async} uniformly as +#wait_result+ work correctly
|
|
180
|
-
# regardless of whether a Task or PendingOperation is returned.
|
|
181
155
|
alias_method :wait_result, :blocking_wait
|
|
182
156
|
|
|
183
|
-
# Registers a callback to be called when the operation
|
|
184
|
-
#
|
|
185
|
-
#
|
|
186
|
-
#
|
|
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.
|
|
187
163
|
#
|
|
188
164
|
# The callback receives +result+ and +error+ (one of them will be +nil+).
|
|
189
165
|
#
|
|
@@ -205,7 +181,7 @@ module Phronomy
|
|
|
205
181
|
end
|
|
206
182
|
|
|
207
183
|
# @api private
|
|
208
|
-
def initialize(block, timeout: nil, cancellation_token: nil, on_abandoned: nil)
|
|
184
|
+
def initialize(block, timeout: nil, cancellation_token: nil, on_abandoned: nil, submitted_at: nil)
|
|
209
185
|
@block = block
|
|
210
186
|
@timeout = timeout
|
|
211
187
|
@cancellation_token = cancellation_token
|
|
@@ -213,35 +189,117 @@ module Phronomy
|
|
|
213
189
|
@value = nil
|
|
214
190
|
@error = nil
|
|
215
191
|
@done = false
|
|
192
|
+
@timed_out = false
|
|
193
|
+
@started = false
|
|
216
194
|
@abandoned = false
|
|
217
195
|
@wait_time = nil
|
|
218
|
-
@submitted_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
196
|
+
@submitted_at = submitted_at || Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
219
197
|
@mutex = Mutex.new
|
|
220
198
|
@cond = ConditionVariable.new
|
|
221
199
|
end
|
|
222
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.
|
|
223
263
|
# @api private
|
|
224
264
|
def execute!
|
|
225
265
|
@wait_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @submitted_at
|
|
226
266
|
|
|
227
|
-
|
|
228
|
-
|
|
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) }
|
|
229
290
|
return
|
|
230
291
|
end
|
|
231
292
|
|
|
293
|
+
return unless should_run
|
|
294
|
+
|
|
232
295
|
# Do NOT use Timeout.timeout here — it delivers an async Thread#raise
|
|
233
296
|
# that can corrupt external library state (mutexes, C extensions, etc.).
|
|
234
|
-
#
|
|
235
|
-
# Each blocking library (Net::HTTP, pg, redis, etc.) should set its
|
|
236
|
-
# own native connection/read timeouts.
|
|
297
|
+
# Each blocking library should set its own native connection/read timeout.
|
|
237
298
|
begin
|
|
238
299
|
complete_with_value!(@block.call)
|
|
239
300
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
240
|
-
# Rescue all Exception subclasses
|
|
241
|
-
#
|
|
242
|
-
# still complete the operation and unblock any waiting #await callers.
|
|
243
|
-
# Without this, a ScriptError in a pool worker would leave the
|
|
244
|
-
# PendingOperation permanently incomplete, causing #await to deadlock.
|
|
301
|
+
# Rescue all Exception subclasses so non-StandardError raises still
|
|
302
|
+
# settle the operation and unblock waiters.
|
|
245
303
|
complete_with_error!(e)
|
|
246
304
|
raise if e.is_a?(SignalException) || e.is_a?(SystemExit)
|
|
247
305
|
end
|
|
@@ -250,27 +308,33 @@ module Phronomy
|
|
|
250
308
|
private
|
|
251
309
|
|
|
252
310
|
def complete_with_value!(value)
|
|
253
|
-
|
|
311
|
+
callbacks = nil
|
|
254
312
|
@mutex.synchronize do
|
|
313
|
+
return false if @done
|
|
314
|
+
|
|
255
315
|
@value = value
|
|
256
316
|
@done = true
|
|
257
317
|
@cond.broadcast
|
|
258
|
-
|
|
318
|
+
callbacks = @callbacks
|
|
259
319
|
@callbacks = nil
|
|
260
320
|
end
|
|
261
|
-
|
|
321
|
+
callbacks&.each { |callback| callback.call(value, nil) }
|
|
322
|
+
true
|
|
262
323
|
end
|
|
263
324
|
|
|
264
325
|
def complete_with_error!(error)
|
|
265
|
-
|
|
326
|
+
callbacks = nil
|
|
266
327
|
@mutex.synchronize do
|
|
328
|
+
return false if @done
|
|
329
|
+
|
|
267
330
|
@error = error
|
|
268
331
|
@done = true
|
|
269
332
|
@cond.broadcast
|
|
270
|
-
|
|
333
|
+
callbacks = @callbacks
|
|
271
334
|
@callbacks = nil
|
|
272
335
|
end
|
|
273
|
-
|
|
336
|
+
callbacks&.each { |callback| callback.call(nil, error) }
|
|
337
|
+
true
|
|
274
338
|
end
|
|
275
339
|
end
|
|
276
340
|
|
|
@@ -278,12 +342,15 @@ module Phronomy
|
|
|
278
342
|
# @param queue_size [Integer] maximum pending operations waiting for a worker
|
|
279
343
|
# @param name [String, Symbol, nil] optional pool name used in thread labels
|
|
280
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.
|
|
281
347
|
# @api private
|
|
282
|
-
def initialize(pool_size: 10, queue_size: 100, name: nil, logger: nil)
|
|
348
|
+
def initialize(pool_size: 10, queue_size: 100, name: nil, logger: nil, timer_queue_provider: nil)
|
|
283
349
|
@pool_size = pool_size
|
|
284
350
|
@queue_size = queue_size
|
|
285
351
|
@name = name
|
|
286
352
|
@logger = logger
|
|
353
|
+
@timer_queue_provider = timer_queue_provider
|
|
287
354
|
@queue = SizedQueue.new(queue_size)
|
|
288
355
|
@active_count = 0
|
|
289
356
|
@abandoned_count = 0
|
|
@@ -295,35 +362,73 @@ module Phronomy
|
|
|
295
362
|
end
|
|
296
363
|
|
|
297
364
|
# Submits a blocking operation to the pool.
|
|
298
|
-
# Returns a {PendingOperation} immediately; the block runs
|
|
365
|
+
# Returns a {PendingOperation} immediately after queue admission; the block runs
|
|
366
|
+
# on a worker thread.
|
|
299
367
|
#
|
|
300
|
-
#
|
|
301
|
-
#
|
|
302
|
-
#
|
|
303
|
-
#
|
|
304
|
-
#
|
|
305
|
-
#
|
|
306
|
-
#
|
|
307
|
-
#
|
|
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
|
|
308
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+
|
|
309
382
|
# @yield block containing the blocking call
|
|
310
383
|
# @return [PendingOperation]
|
|
384
|
+
# @raise [Phronomy::ConfigurationError] when +timeout+ is specified without a
|
|
385
|
+
# timer queue provider
|
|
311
386
|
# @raise [Phronomy::PoolShutdownError] when the pool has been shut down
|
|
312
387
|
# @raise [Phronomy::BackpressureError] when +on_full: :raise+ and queue is full
|
|
313
|
-
# @raise [Phronomy::TimeoutError] when +on_full: :timeout+
|
|
388
|
+
# @raise [Phronomy::TimeoutError] when +on_full: :timeout+ exceeds +full_timeout+
|
|
314
389
|
# @api private
|
|
315
390
|
def submit(timeout: nil, cancellation_token: nil, on_full: :wait, full_timeout: nil, &block)
|
|
316
391
|
raise Phronomy::PoolShutdownError, "pool has been shut down" if @shutdown
|
|
317
392
|
|
|
318
|
-
|
|
319
|
-
|
|
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
|
+
|
|
320
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
|
+
|
|
321
425
|
case on_full
|
|
322
426
|
when :raise
|
|
323
427
|
begin
|
|
324
428
|
@queue.push(op, true)
|
|
325
429
|
rescue ThreadError
|
|
326
|
-
raise Phronomy::BackpressureError,
|
|
430
|
+
raise Phronomy::BackpressureError,
|
|
431
|
+
"BlockingAdapterPool queue is full (depth: #{@queue_size})"
|
|
327
432
|
end
|
|
328
433
|
when :timeout
|
|
329
434
|
deadline = full_timeout ? (Process.clock_gettime(Process::CLOCK_MONOTONIC) + full_timeout) : nil
|
|
@@ -332,17 +437,23 @@ module Phronomy
|
|
|
332
437
|
break
|
|
333
438
|
rescue ThreadError
|
|
334
439
|
if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
335
|
-
raise Phronomy::TimeoutError,
|
|
440
|
+
raise Phronomy::TimeoutError,
|
|
441
|
+
"timed out waiting for a free slot in BlockingAdapterPool"
|
|
336
442
|
end
|
|
337
443
|
sleep(0.005)
|
|
338
444
|
end
|
|
339
445
|
else # :wait (default)
|
|
340
446
|
@queue.push(op)
|
|
341
447
|
end
|
|
342
|
-
rescue ClosedQueueError
|
|
343
|
-
# Shutdown raced with this submit —
|
|
448
|
+
rescue ClosedQueueError => e
|
|
449
|
+
# Shutdown raced with this submit — preserve the existing public error.
|
|
450
|
+
op.fail_submission!(e)
|
|
344
451
|
raise Phronomy::PoolShutdownError, "pool has been shut down"
|
|
452
|
+
rescue => e
|
|
453
|
+
op.fail_submission!(e)
|
|
454
|
+
raise
|
|
345
455
|
end
|
|
456
|
+
|
|
346
457
|
op
|
|
347
458
|
end
|
|
348
459
|
|
|
@@ -358,7 +469,7 @@ module Phronomy
|
|
|
358
469
|
def shutdown(drain_timeout: 30)
|
|
359
470
|
@shutdown = true
|
|
360
471
|
@queue.close
|
|
361
|
-
@workers.each { |
|
|
472
|
+
@workers.each { |thread| thread.join(drain_timeout) }
|
|
362
473
|
self
|
|
363
474
|
end
|
|
364
475
|
|
|
@@ -376,14 +487,15 @@ module Phronomy
|
|
|
376
487
|
@queue.size
|
|
377
488
|
end
|
|
378
489
|
|
|
379
|
-
# @return [Integer] number of operations
|
|
490
|
+
# @return [Integer] number of operations whose caller-facing timeout fired
|
|
491
|
+
# after worker execution had started
|
|
380
492
|
# @api private
|
|
381
493
|
def abandoned_count
|
|
382
494
|
@mutex.synchronize { @abandoned_count }
|
|
383
495
|
end
|
|
384
496
|
|
|
385
|
-
# Average time (in seconds) that completed operations spent in the
|
|
386
|
-
# waiting for a worker.
|
|
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.
|
|
387
499
|
# @return [Float]
|
|
388
500
|
# @api private
|
|
389
501
|
def average_wait_seconds
|
|
@@ -76,7 +76,11 @@ module Phronomy
|
|
|
76
76
|
#
|
|
77
77
|
# Callbacks are NOT fired for deadline-based cancellation (i.e. when
|
|
78
78
|
# {#cancelled?} returns +true+ due to +@monotonic_deadline+ expiry). Use
|
|
79
|
-
# {
|
|
79
|
+
# {Phronomy::Concurrency::CancellationScope#deadline_in}, which registers
|
|
80
|
+
# a timer via {Runtime#timer_queue} and calls {#cancel!} on expiry — this
|
|
81
|
+
# fires all +on_cancel+ callbacks automatically. {.timeout_after} is a
|
|
82
|
+
# lightweight alternative that uses lazy clock comparison only and does
|
|
83
|
+
# NOT trigger callbacks on expiry.
|
|
80
84
|
#
|
|
81
85
|
# @yield called with no arguments when (or if) the token is cancelled
|
|
82
86
|
# @return [self]
|
|
@@ -9,7 +9,10 @@ module Phronomy
|
|
|
9
9
|
# All pools are shut down together by {#shutdown}.
|
|
10
10
|
# @api private
|
|
11
11
|
class PoolRegistry
|
|
12
|
-
|
|
12
|
+
# @param timer_queue_provider [#call, nil] provider passed to every pool
|
|
13
|
+
# @api private
|
|
14
|
+
def initialize(timer_queue_provider: nil)
|
|
15
|
+
@timer_queue_provider = timer_queue_provider
|
|
13
16
|
@mutex = Mutex.new
|
|
14
17
|
@pools = {}
|
|
15
18
|
@default = nil
|
|
@@ -24,7 +27,8 @@ module Phronomy
|
|
|
24
27
|
@default ||= BlockingAdapterPool.new(
|
|
25
28
|
name: :default,
|
|
26
29
|
pool_size: pool_size,
|
|
27
|
-
queue_size: queue_size
|
|
30
|
+
queue_size: queue_size,
|
|
31
|
+
timer_queue_provider: @timer_queue_provider
|
|
28
32
|
)
|
|
29
33
|
end
|
|
30
34
|
|
|
@@ -39,7 +43,8 @@ module Phronomy
|
|
|
39
43
|
@pools[name.to_sym] ||= BlockingAdapterPool.new(
|
|
40
44
|
name: name,
|
|
41
45
|
pool_size: size,
|
|
42
|
-
queue_size: queue_size
|
|
46
|
+
queue_size: queue_size,
|
|
47
|
+
timer_queue_provider: @timer_queue_provider
|
|
43
48
|
)
|
|
44
49
|
end
|
|
45
50
|
end
|