phronomy 0.20.0 → 0.22.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.
@@ -22,7 +22,7 @@ module Phronomy
22
22
  #
23
23
  # 1. The total number of worker OS threads is capped.
24
24
  # 2. Queue depth is bounded (backpressure when the pool is saturated).
25
- # 3. Per-operation timeouts and cancellation settle the caller-facing handle.
25
+ # 3. Per-operation timeouts and cancellation settle the caller-facing Task.
26
26
  # 4. Operations that settle after worker execution has started are tracked as
27
27
  # abandoned until that worker returns.
28
28
  # 5. Metrics expose active work, queue depth, cumulative abandonment,
@@ -33,128 +33,24 @@ module Phronomy
33
33
  # resource isolation may use named pools via {Runtime#pool}.
34
34
  #
35
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
36
+ # task = runtime.offload.submit(timeout: 30) { expensive_call }
37
+ # result = task.wait_result
38
38
  #
39
39
  # @example With cancellation
40
40
  # token = Phronomy::Concurrency::CancellationToken.timeout_after(60)
41
- # op = pool.submit(timeout: 30, cancellation_token: token) { expensive_call }
42
- # result = op.blocking_wait
41
+ # task = pool.submit(timeout: 30, cancellation_token: token) { expensive_call }
42
+ # result = task.wait_result
43
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
44
+ # Private execution record for one submitted synchronous operation.
45
+ #
46
+ # Caller-facing completion is represented exclusively by {Phronomy::Task}.
47
+ # This object owns only OffloadPool-specific execution state: queue timing,
48
+ # worker-start linearization, submit timeout/cancellation, abandonment, and
49
+ # the submitted block itself.
50
+ #
51
+ # @api private
52
+ class Operation
53
+ attr_reader :task
158
54
 
159
55
  # @api private
160
56
  def initialize(
@@ -162,15 +58,15 @@ module Phronomy
162
58
  timeout: nil,
163
59
  cancellation_token: nil,
164
60
  on_abandoned: nil,
165
- submitted_at: nil
61
+ submitted_at: nil,
62
+ task_name: nil
166
63
  )
167
64
  @block = block
168
65
  @timeout = timeout
169
66
  @cancellation_token = cancellation_token
170
67
  @on_abandoned = on_abandoned
171
- @value = nil
172
- @error = nil
173
- @done = false
68
+ @task = Phronomy::Task.deferred(name: task_name)
69
+ @settled = false
174
70
  @timed_out = false
175
71
  @cancelled = false
176
72
  @started = false
@@ -179,7 +75,6 @@ module Phronomy
179
75
  @submitted_at = submitted_at ||
180
76
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
181
77
  @mutex = Mutex.new
182
- @cond = ConditionVariable.new
183
78
 
184
79
  # Explicit submit cancellation is operation-wide. Deadline-only tokens are
185
80
  # promoted to cancel! by OffloadPool#submit using the Runtime timer queue.
@@ -189,32 +84,59 @@ module Phronomy
189
84
  @cancellation_token&.on_cancel(&@cancellation_callback)
190
85
  end
191
86
 
192
- # Settles the operation with a submit-time timeout.
87
+ # @return [Boolean] true when caller-facing settlement has been claimed
88
+ # @api private
89
+ def settled?
90
+ @mutex.synchronize { @settled }
91
+ end
92
+
93
+ # @return [Boolean] true when the submit-time deadline settled the Task
94
+ # @api private
95
+ def timed_out?
96
+ @mutex.synchronize { @timed_out }
97
+ end
98
+
99
+ # @return [Boolean] true when submit cancellation settled the Task
100
+ # @api private
101
+ def cancelled?
102
+ @mutex.synchronize { @cancelled }
103
+ end
104
+
105
+ # @return [Boolean] true when timeout/cancellation settled the Task after
106
+ # worker execution had started. The worker is not forcibly interrupted.
107
+ # @api private
108
+ def abandoned?
109
+ @mutex.synchronize { @abandoned }
110
+ end
111
+
112
+ # @return [Float] seconds spent in the queue before execution started
113
+ # @api private
114
+ def wait_time
115
+ @wait_time || 0.0
116
+ end
117
+
118
+ # Settles the caller-facing Task with a submit-time timeout.
193
119
  #
194
120
  # The worker is not interrupted. If execution has already started, the
195
121
  # operation is marked abandoned and the worker's eventual result is discarded.
196
122
  #
197
- # @return [Boolean] true when this call settled the operation, false when the
198
- # operation had already settled
123
+ # @return [Boolean] true when this call won settlement
199
124
  # @api private
200
125
  def fire_timeout!
201
- settle_early!(timed_out: true) do
126
+ settle_early!(timed_out: true, cancelled: false) do
202
127
  Phronomy::TimeoutError.new(
203
128
  "offloaded operation timed out after #{@timeout}s"
204
129
  )
205
130
  end
206
131
  end
207
132
 
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.
133
+ # Settles the caller-facing Task because its submit cancellation token was
134
+ # cancelled. Cancellation never injects Thread#raise into the worker.
213
135
  #
214
- # @return [Boolean] true when this call settled the operation
136
+ # @return [Boolean] true when this call won settlement
215
137
  # @api private
216
138
  def fire_cancellation!
217
- settle_early!(cancelled: true) do |started|
139
+ settle_early!(timed_out: false, cancelled: true) do |started|
218
140
  message = if started
219
141
  "offloaded operation cancelled during execution"
220
142
  else
@@ -224,27 +146,22 @@ module Phronomy
224
146
  end
225
147
  end
226
148
 
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.
149
+ # Marks an operation that could not be admitted to the pool as settled so
150
+ # previously armed timers become harmless no-ops.
229
151
  #
230
152
  # @param error [Exception, nil]
231
- # @return [Boolean] true when this call changed the state
153
+ # @return [Boolean] true when this call won settlement
232
154
  # @api private
233
155
  def fail_submission!(error = nil)
234
- callbacks = nil
235
- changed = @mutex.synchronize do
236
- next false if @done
156
+ changed = claim_terminal!
157
+ return false unless changed
237
158
 
238
- @done = true
239
- @error = error if error
240
- @cond.broadcast
241
- callbacks = @callbacks
242
- @callbacks = nil
243
- true
159
+ detach_submit_cancellation
160
+ if error
161
+ @task.fail(error)
162
+ else
163
+ @task.complete(nil)
244
164
  end
245
-
246
- detach_submit_cancellation if changed
247
- deliver_completion_callbacks(callbacks, nil, error) if changed
248
165
  changed
249
166
  end
250
167
 
@@ -261,7 +178,7 @@ module Phronomy
261
178
  end
262
179
 
263
180
  should_run = @mutex.synchronize do
264
- if @done
181
+ if @settled
265
182
  false
266
183
  else
267
184
  # Linearization point: after this assignment, a concurrent timeout or
@@ -281,7 +198,7 @@ module Phronomy
281
198
  complete_with_value!(@block.call)
282
199
  rescue Exception => e # rubocop:disable Lint/RescueException
283
200
  # Rescue all Exception subclasses so non-StandardError raises still
284
- # settle the operation and unblock waiters.
201
+ # settle the Task and unblock waiters.
285
202
  complete_with_error!(e)
286
203
  raise if e.is_a?(SignalException) || e.is_a?(SystemExit)
287
204
  end
@@ -289,38 +206,41 @@ module Phronomy
289
206
 
290
207
  private
291
208
 
292
- def settle_early!(timed_out: false, cancelled: false)
293
- callbacks = nil
209
+ def settle_early!(timed_out:, cancelled:)
294
210
  abandoned_now = false
295
211
  error = nil
296
-
297
212
  changed = @mutex.synchronize do
298
- next false if @done
213
+ next false if @settled
299
214
 
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
215
  error = yield(@started)
305
- @done = true
216
+ @settled = true
306
217
  @timed_out = timed_out
307
218
  @cancelled = cancelled
308
- @error = error
309
219
  @abandoned = @started
310
220
  abandoned_now = @abandoned
311
- @cond.broadcast
312
- callbacks = @callbacks
313
- @callbacks = nil
314
221
  true
315
222
  end
316
223
  return false unless changed
317
224
 
318
225
  detach_submit_cancellation
319
226
  notify_abandoned if abandoned_now
320
- deliver_completion_callbacks(callbacks, nil, error)
227
+ if cancelled
228
+ @task.cancel!(error)
229
+ else
230
+ @task.fail(error)
231
+ end
321
232
  true
322
233
  end
323
234
 
235
+ def claim_terminal!
236
+ @mutex.synchronize do
237
+ next false if @settled
238
+
239
+ @settled = true
240
+ true
241
+ end
242
+ end
243
+
324
244
  def notify_abandoned
325
245
  @on_abandoned&.call(self)
326
246
  rescue => error
@@ -330,52 +250,21 @@ module Phronomy
330
250
  end
331
251
 
332
252
  def complete_with_value!(value)
333
- callbacks = nil
334
- changed = @mutex.synchronize do
335
- next false if @done
253
+ changed = claim_terminal!
254
+ return false unless changed
336
255
 
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
256
+ detach_submit_cancellation
257
+ @task.complete(value)
258
+ true
347
259
  end
348
260
 
349
261
  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
262
+ changed = claim_terminal!
263
+ return false unless changed
371
264
 
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
265
+ detach_submit_cancellation
266
+ @task.fail(error)
267
+ true
379
268
  end
380
269
 
381
270
  def detach_submit_cancellation
@@ -388,6 +277,7 @@ module Phronomy
388
277
  @cancellation_callback = nil
389
278
  end
390
279
  end
280
+ private_constant :Operation
391
281
 
392
282
  # @param pool_size [Integer] maximum number of worker threads
393
283
  # @param queue_size [Integer] maximum pending operations waiting for a worker
@@ -422,24 +312,25 @@ module Phronomy
422
312
  end
423
313
 
424
314
  # 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
315
+ #
316
+ # Returns a {Phronomy::Task} immediately after queue admission; the block
317
+ # runs on a worker thread. Do not submit logical waits (for example waiting
318
+ # for a child Agent Task) merely to make them asynchronous; those belong to
428
319
  # FSMSession/EventLoop completion events.
429
320
  #
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.
321
+ # A submit-time +timeout+ is an operation-wide deadline measured from the
322
+ # start of this method, including queue wait. The timer settles the Task and
323
+ # notifies +on_complete+ without forcibly interrupting a running worker. If
324
+ # the deadline fires before worker execution starts, the block is skipped.
325
+ # If it fires after execution starts, the private Operation is marked
326
+ # abandoned and the eventual worker result is discarded.
436
327
  #
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.
328
+ # The submit +cancellation_token+ is also operation-wide. Explicit
329
+ # cancellation settles the Task immediately. A token with a monotonic
330
+ # deadline is attached to the Runtime timer queue so deadline expiry becomes
331
+ # explicit cancellation without adding a polling Thread. Cancellation before
332
+ # execution skips the block; cancellation after execution starts abandons
333
+ # only the caller-facing result and never uses Thread#raise.
443
334
  #
444
335
  # Synchronous queue admission may delay return from this method when
445
336
  # +on_full: :wait+ is used. EventLoop-owned framework paths therefore submit
@@ -450,7 +341,7 @@ module Phronomy
450
341
  # @param on_full [Symbol] +:wait+, +:raise+, or +:timeout+
451
342
  # @param full_timeout [Numeric, nil] queue-admission timeout for +on_full: :timeout+
452
343
  # @yield block containing synchronous work
453
- # @return [PendingOperation]
344
+ # @return [Phronomy::Task]
454
345
  # @raise [Phronomy::ConfigurationError] when a timer is required but no
455
346
  # timer queue provider is configured
456
347
  # @raise [Phronomy::PoolShutdownError] when the pool has been shut down
@@ -482,19 +373,21 @@ module Phronomy
482
373
  "timer_queue is required when submit timeout or cancellation deadline is specified"
483
374
  end
484
375
 
485
- op = PendingOperation.new(
376
+ operation = Operation.new(
486
377
  block,
487
378
  timeout: timeout,
488
379
  cancellation_token: cancellation_token,
489
380
  submitted_at: submitted_at,
381
+ task_name: offload_task_name,
490
382
  on_abandoned: method(:record_abandoned)
491
383
  )
384
+ task = operation.task
492
385
 
493
386
  # on_cancel only reacts to explicit cancel!, whereas cancelled? also covers a
494
387
  # monotonic deadline. Promote an already-expired deadline immediately.
495
388
  if already_cancelled
496
389
  cancellation_token.cancel!
497
- return op
390
+ return task
498
391
  end
499
392
 
500
393
  begin
@@ -502,13 +395,13 @@ module Phronomy
502
395
  elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - submitted_at
503
396
  remaining = timeout.to_f - elapsed
504
397
  if remaining <= 0
505
- op.fire_timeout!
506
- return op
398
+ operation.fire_timeout!
399
+ return task
507
400
  end
508
401
 
509
402
  # Arm before queue admission so the deadline includes time spent waiting
510
403
  # for a queue slot.
511
- timer_queue.schedule(seconds: remaining) { op.fire_timeout! }
404
+ timer_queue.schedule(seconds: remaining) { operation.fire_timeout! }
512
405
  end
513
406
 
514
407
  if cancellation_remaining
@@ -516,19 +409,19 @@ module Phronomy
516
409
  remaining = cancellation_token.remaining_monotonic_seconds
517
410
  if remaining <= 0
518
411
  cancellation_token.cancel!
519
- return op
412
+ return task
520
413
  end
521
414
  timer_queue.schedule(seconds: remaining) { cancellation_token.cancel! }
522
415
  end
523
416
 
524
417
  # Cancellation/timeout can race with timer registration. Do not enqueue
525
418
  # already-settled work when the race is observable here.
526
- return op if op.done?
419
+ return task if operation.settled?
527
420
 
528
421
  case on_full
529
422
  when :raise
530
423
  begin
531
- @queue.push(op, true)
424
+ @queue.push(operation, true)
532
425
  rescue ThreadError
533
426
  raise Phronomy::BackpressureError,
534
427
  "OffloadPool queue is full (depth: #{@queue_size})"
@@ -538,9 +431,9 @@ module Phronomy
538
431
  (Process.clock_gettime(Process::CLOCK_MONOTONIC) + full_timeout) :
539
432
  nil
540
433
  loop do
541
- return op if op.done?
434
+ return task if operation.settled?
542
435
 
543
- @queue.push(op, true)
436
+ @queue.push(operation, true)
544
437
  break
545
438
  rescue ThreadError
546
439
  if deadline &&
@@ -551,18 +444,18 @@ module Phronomy
551
444
  sleep(0.005)
552
445
  end
553
446
  else # :wait (default)
554
- @queue.push(op)
447
+ @queue.push(operation)
555
448
  end
556
449
  rescue ClosedQueueError => e
557
450
  # Shutdown raced with this submit — preserve the existing public error.
558
- op.fail_submission!(e)
451
+ operation.fail_submission!(e)
559
452
  raise Phronomy::PoolShutdownError, "pool has been shut down"
560
453
  rescue => e
561
- op.fail_submission!(e)
454
+ operation.fail_submission!(e)
562
455
  raise
563
456
  end
564
457
 
565
- op
458
+ task
566
459
  end
567
460
 
568
461
  # Gracefully drains the pool and terminates all worker threads.
@@ -635,20 +528,24 @@ module Phronomy
635
528
  SENTINEL = :shutdown
636
529
  private_constant :SENTINEL
637
530
 
531
+ def offload_task_name
532
+ @name ? "offload-#{@name}" : "offload"
533
+ end
534
+
638
535
  def spawn_worker(index = nil)
639
536
  label = ["phronomy", "offload-pool", @name, index].compact.join("-")
640
537
  Thread.new do
641
538
  Thread.current.name = label
642
539
  loop do
643
- op = begin
540
+ operation = begin
644
541
  @queue.pop
645
542
  rescue ClosedQueueError
646
543
  break
647
544
  end
648
545
  # nil is returned by a closed, empty Queue on some Ruby versions
649
- break if op.nil? || op == SENTINEL
546
+ break if operation.nil? || operation == SENTINEL
650
547
 
651
- run_operation(op)
548
+ run_operation(operation)
652
549
  end
653
550
  end
654
551
  end
@@ -663,18 +560,18 @@ module Phronomy
663
560
  end
664
561
  end
665
562
 
666
- def run_operation(op)
667
- operation_id = op.object_id
563
+ def run_operation(operation)
564
+ operation_id = operation.object_id
668
565
  @mutex.synchronize do
669
566
  @active_count += 1
670
567
  @running_operation_ids[operation_id] = true
671
568
  end
672
569
 
673
570
  begin
674
- op.execute!
571
+ operation.execute!
675
572
  ensure
676
- abandoned = op.abandoned?
677
- wait_ns = (op.wait_time * 1_000_000_000).to_i
573
+ abandoned = operation.abandoned?
574
+ wait_ns = (operation.wait_time * 1_000_000_000).to_i
678
575
 
679
576
  @mutex.synchronize do
680
577
  @active_count -= 1