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.
Files changed (123) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -9
  3. data/CHANGELOG.md +151 -1134
  4. data/CONTRIBUTING.md +28 -16
  5. data/README.md +69 -1249
  6. data/benchmark/baseline.json +2 -3
  7. data/benchmark/bench_agent_invoke.rb +4 -4
  8. data/benchmark/bench_context_assembler.rb +134 -34
  9. data/benchmark/bench_regression.rb +26 -6
  10. data/benchmark/bench_tool_schema.rb +2 -35
  11. data/docs/changelog/0.14-and-earlier.md +1137 -0
  12. data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
  13. data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
  14. data/docs/decisions/010-cooperative-first-concurrency.md +155 -228
  15. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +2 -2
  16. data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
  17. data/docs/features.md +87 -0
  18. data/docs/getting-started.md +351 -0
  19. data/docs/migrations/0.15.md +35 -0
  20. data/docs/migrations/0.16.md +43 -0
  21. data/docs/runtime-and-concurrency.md +258 -0
  22. data/examples/workflows/generic_task_event_mapping.rb +14 -6
  23. data/lib/phronomy/agent/agent_invocation.rb +2 -36
  24. data/lib/phronomy/agent/agent_invocation_session_builder.rb +157 -94
  25. data/lib/phronomy/agent/agent_root.rb +1 -2
  26. data/lib/phronomy/agent/async_event_api.rb +3 -3
  27. data/lib/phronomy/agent/base.rb +170 -333
  28. data/lib/phronomy/agent/context/capability/base.rb +177 -298
  29. data/lib/phronomy/agent/context_assembler.rb +65 -29
  30. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
  31. data/lib/phronomy/agent/context_plan_validator.rb +0 -33
  32. data/lib/phronomy/agent/execution_coordinator.rb +6 -7
  33. data/lib/phronomy/agent/journal_projection.rb +28 -2
  34. data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
  35. data/lib/phronomy/agent/shared_state.rb +48 -138
  36. data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
  37. data/lib/phronomy/agent/tool_executor.rb +29 -71
  38. data/lib/phronomy/agent/tool_invocation.rb +180 -336
  39. data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
  40. data/lib/phronomy/agent.rb +6 -10
  41. data/lib/phronomy/configuration.rb +4 -171
  42. data/lib/phronomy/diagnostics.rb +12 -41
  43. data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
  44. data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
  45. data/lib/phronomy/engine/concurrency/cancellation_token.rb +51 -79
  46. data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
  47. data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
  48. data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
  49. data/lib/phronomy/engine/event_loop.rb +89 -190
  50. data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
  51. data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
  52. data/lib/phronomy/engine/runtime.rb +49 -377
  53. data/lib/phronomy/engine/task.rb +136 -277
  54. data/lib/phronomy/llm_adapter/base.rb +14 -14
  55. data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
  56. data/lib/phronomy/llm_adapter.rb +2 -2
  57. data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
  58. data/lib/phronomy/metrics.rb +15 -30
  59. data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
  60. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
  61. data/lib/phronomy/multi_agent/handoff.rb +1 -0
  62. data/lib/phronomy/multi_agent/orchestrator.rb +252 -256
  63. data/lib/phronomy/multi_agent/team_coordinator.rb +44 -133
  64. data/lib/phronomy/testing/eval/comparison.rb +23 -0
  65. data/lib/phronomy/testing/eval/dataset.rb +27 -0
  66. data/lib/phronomy/testing/eval/eval_case.rb +13 -0
  67. data/lib/phronomy/testing/eval/eval_result.rb +16 -0
  68. data/lib/phronomy/testing/eval/metrics.rb +43 -0
  69. data/lib/phronomy/testing/eval/runner.rb +52 -0
  70. data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
  71. data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
  72. data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
  73. data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
  74. data/lib/phronomy/testing/eval/scorer.rb +10 -0
  75. data/lib/phronomy/testing/eval.rb +9 -0
  76. data/lib/phronomy/testing/fake_clock.rb +6 -53
  77. data/lib/phronomy/testing.rb +2 -6
  78. data/lib/phronomy/tools/agent.rb +141 -6
  79. data/lib/phronomy/vector_store/async_backend.rb +21 -17
  80. data/lib/phronomy/vector_store/base.rb +2 -2
  81. data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
  82. data/lib/phronomy/vector_store/in_memory.rb +2 -2
  83. data/lib/phronomy/version.rb +1 -1
  84. data/lib/phronomy/workflow_runner.rb +2 -4
  85. data/lib/phronomy.rb +7 -121
  86. data/scripts/api_snapshot.rb +4 -15
  87. metadata +24 -38
  88. data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
  89. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
  90. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
  91. data/lib/phronomy/agent/fsm_runtime_adapter.rb +0 -210
  92. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
  93. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
  94. data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
  95. data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
  96. data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
  97. data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
  98. data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
  99. data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
  100. data/lib/phronomy/engine/task/backend.rb +0 -80
  101. data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
  102. data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
  103. data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
  104. data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
  105. data/lib/phronomy/engine/task/thread_backend.rb +0 -84
  106. data/lib/phronomy/engine/task_group.rb +0 -265
  107. data/lib/phronomy/eval/comparison.rb +0 -47
  108. data/lib/phronomy/eval/dataset.rb +0 -45
  109. data/lib/phronomy/eval/eval_case.rb +0 -17
  110. data/lib/phronomy/eval/eval_result.rb +0 -29
  111. data/lib/phronomy/eval/metrics.rb +0 -66
  112. data/lib/phronomy/eval/runner.rb +0 -94
  113. data/lib/phronomy/eval/scorer/base.rb +0 -22
  114. data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
  115. data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
  116. data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
  117. data/lib/phronomy/eval/scorer.rb +0 -9
  118. data/lib/phronomy/eval.rb +0 -7
  119. data/lib/phronomy/knowledge_source.rb +0 -12
  120. data/lib/phronomy/llm_context_window/assembler.rb +0 -191
  121. data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
  122. data/lib/phronomy/testing/fake_scheduler.rb +0 -104
  123. 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