phronomy 0.17.0 → 0.19.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +122 -1134
  3. data/README.md +70 -1280
  4. data/benchmark/bench_regression.rb +25 -5
  5. data/docs/changelog/0.14-and-earlier.md +1137 -0
  6. data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
  7. data/docs/decisions/009-state-store-abstraction.md +1 -1
  8. data/docs/decisions/010-cooperative-first-concurrency.md +155 -235
  9. data/docs/decisions/014-unified-persistence-durable-state.md +268 -0
  10. data/docs/features.md +93 -0
  11. data/docs/getting-started.md +387 -0
  12. data/docs/migrations/0.15.md +35 -0
  13. data/docs/migrations/0.16.md +43 -0
  14. data/docs/migrations/0.19.md +148 -0
  15. data/docs/runtime-and-concurrency.md +349 -0
  16. data/examples/workflows/generic_task_event_mapping.rb +14 -6
  17. data/lib/phronomy/agent/agent_invocation_session_builder.rb +2 -2
  18. data/lib/phronomy/agent/async_event_api.rb +3 -3
  19. data/lib/phronomy/agent/base.rb +116 -55
  20. data/lib/phronomy/agent/context/capability/base.rb +13 -3
  21. data/lib/phronomy/agent/context_assembler.rb +13 -3
  22. data/lib/phronomy/agent/execution_coordinator.rb +423 -252
  23. data/lib/phronomy/agent/journal_projection.rb +5 -1
  24. data/lib/phronomy/agent/shared_state.rb +2 -0
  25. data/lib/phronomy/agent/tool_executor.rb +29 -71
  26. data/lib/phronomy/agent/tool_invocation.rb +97 -47
  27. data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
  28. data/lib/phronomy/configuration.rb +7 -30
  29. data/lib/phronomy/diagnostics.rb +12 -41
  30. data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
  31. data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
  32. data/lib/phronomy/engine/concurrency/cancellation_token.rb +48 -3
  33. data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
  34. data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
  35. data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
  36. data/lib/phronomy/engine/event_loop.rb +170 -193
  37. data/lib/phronomy/engine/fsm_session.rb +6 -4
  38. data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
  39. data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
  40. data/lib/phronomy/engine/runtime.rb +52 -158
  41. data/lib/phronomy/engine/task.rb +136 -277
  42. data/lib/phronomy/llm_adapter/base.rb +14 -14
  43. data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
  44. data/lib/phronomy/llm_adapter.rb +2 -2
  45. data/lib/phronomy/metrics.rb +15 -30
  46. data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
  47. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
  48. data/lib/phronomy/multi_agent/handoff.rb +1 -0
  49. data/lib/phronomy/multi_agent/orchestrator.rb +147 -99
  50. data/lib/phronomy/multi_agent/team_coordinator.rb +2 -0
  51. data/lib/phronomy/persistence/in_memory.rb +113 -8
  52. data/lib/phronomy/persistence.rb +12 -3
  53. data/lib/phronomy/testing/eval/comparison.rb +23 -0
  54. data/lib/phronomy/testing/eval/dataset.rb +27 -0
  55. data/lib/phronomy/testing/eval/eval_case.rb +13 -0
  56. data/lib/phronomy/testing/eval/eval_result.rb +16 -0
  57. data/lib/phronomy/testing/eval/metrics.rb +43 -0
  58. data/lib/phronomy/testing/eval/runner.rb +52 -0
  59. data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
  60. data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
  61. data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
  62. data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
  63. data/lib/phronomy/testing/eval/scorer.rb +10 -0
  64. data/lib/phronomy/testing/eval.rb +9 -0
  65. data/lib/phronomy/testing/fake_clock.rb +6 -53
  66. data/lib/phronomy/testing.rb +2 -6
  67. data/lib/phronomy/tools/agent.rb +141 -6
  68. data/lib/phronomy/vector_store/async_backend.rb +21 -17
  69. data/lib/phronomy/vector_store/base.rb +2 -2
  70. data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
  71. data/lib/phronomy/version.rb +1 -1
  72. data/lib/phronomy/workflow.rb +10 -9
  73. data/lib/phronomy/workflow_runner.rb +363 -99
  74. data/lib/phronomy.rb +10 -4
  75. data/scripts/api_snapshot.rb +4 -4
  76. metadata +25 -33
  77. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
  78. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
  79. data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
  80. data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
  81. data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
  82. data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
  83. data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
  84. data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
  85. data/lib/phronomy/engine/task/backend.rb +0 -80
  86. data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
  87. data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
  88. data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
  89. data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
  90. data/lib/phronomy/engine/task/thread_backend.rb +0 -84
  91. data/lib/phronomy/engine/task_group.rb +0 -193
  92. data/lib/phronomy/eval/comparison.rb +0 -47
  93. data/lib/phronomy/eval/dataset.rb +0 -45
  94. data/lib/phronomy/eval/eval_case.rb +0 -17
  95. data/lib/phronomy/eval/eval_result.rb +0 -29
  96. data/lib/phronomy/eval/metrics.rb +0 -66
  97. data/lib/phronomy/eval/runner.rb +0 -94
  98. data/lib/phronomy/eval/scorer/base.rb +0 -22
  99. data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
  100. data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
  101. data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
  102. data/lib/phronomy/eval/scorer.rb +0 -9
  103. data/lib/phronomy/eval.rb +0 -7
  104. data/lib/phronomy/state_store/base.rb +0 -48
  105. data/lib/phronomy/state_store/in_memory.rb +0 -62
  106. data/lib/phronomy/testing/fake_scheduler.rb +0 -104
  107. data/lib/phronomy/testing/scheduler_helpers.rb +0 -68
@@ -2,11 +2,9 @@
2
2
 
3
3
  module Phronomy
4
4
  # Holds global configuration for the entire framework.
5
- # Configure via the Phronomy.configure block.
6
5
  class Configuration
7
6
  STREAM_CALLBACK_ERROR_POLICIES = %i[report fail_task].freeze
8
- RUNTIME_BACKENDS = %i[thread immediate fiber].freeze
9
- private_constant :STREAM_CALLBACK_ERROR_POLICIES, :RUNTIME_BACKENDS
7
+ private_constant :STREAM_CALLBACK_ERROR_POLICIES
10
8
 
11
9
  attr_accessor :default_model
12
10
  attr_accessor :default_embedding_model
@@ -18,22 +16,17 @@ module Phronomy
18
16
  attr_accessor :trace_pii
19
17
  attr_accessor :logger
20
18
  attr_accessor :event_loop_stop_grace_seconds
21
- attr_accessor :state_store
19
+ attr_accessor :persistence
22
20
  attr_accessor :tool_result_max_size
23
21
  attr_accessor :llm_adapter
24
22
  attr_accessor :event_loop_starvation_threshold_seconds
25
23
  attr_accessor :event_loop_dispatch_threshold_seconds
26
- attr_accessor :scheduler_debug
27
- attr_accessor :blocking_detect_threshold_ms
28
24
  attr_reader :stream_callback_error_policy
29
- attr_accessor :blocking_io_pool_size
30
- attr_accessor :blocking_io_queue_size
25
+ attr_accessor :offload_pool_size
26
+ attr_accessor :offload_queue_size
31
27
  attr_accessor :authorization_pool_size
32
28
  attr_accessor :authorization_queue_size
33
29
  attr_accessor :authorization_timeout
34
- attr_accessor :starvation_threshold_ms
35
- attr_reader :runtime_backend
36
- attr_accessor :strict_runtime_guards
37
30
 
38
31
  def stream_callback_error_policy=(value)
39
32
  unless STREAM_CALLBACK_ERROR_POLICIES.include?(value)
@@ -45,18 +38,6 @@ module Phronomy
45
38
  @stream_callback_error_policy = value
46
39
  end
47
40
 
48
- # Scheduler backend used for newly-created Runtime instances.
49
- # Supported values are :thread, :immediate, and :fiber.
50
- def runtime_backend=(value)
51
- value = value.to_sym if value.respond_to?(:to_sym)
52
- unless RUNTIME_BACKENDS.include?(value)
53
- allowed = RUNTIME_BACKENDS.map(&:inspect).join(", ")
54
- raise Phronomy::ConfigurationError,
55
- "runtime_backend must be one of: #{allowed}"
56
- end
57
- @runtime_backend = value
58
- end
59
-
60
41
  def initialize
61
42
  @recursion_limit = 25
62
43
  @tracer = Phronomy::Tracing::NullTracer.new
@@ -66,17 +47,13 @@ module Phronomy
66
47
  @llm_adapter = Phronomy::LLMAdapter::RubyLLM.new
67
48
  @event_loop_starvation_threshold_seconds = nil
68
49
  @event_loop_dispatch_threshold_seconds = nil
69
- @scheduler_debug = false
70
- @blocking_detect_threshold_ms = nil
71
50
  @stream_callback_error_policy = :report
72
- @blocking_io_pool_size = 10
73
- @blocking_io_queue_size = 100
51
+ @offload_pool_size = 10
52
+ @offload_queue_size = 100
74
53
  @authorization_pool_size = 4
75
54
  @authorization_queue_size = 100
76
55
  @authorization_timeout = 5
77
- @starvation_threshold_ms = 50
78
- @runtime_backend = :thread
79
- @strict_runtime_guards = false
56
+ @persistence = nil
80
57
  end
81
58
  end
82
59
  end
@@ -1,62 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- # Developer-facing diagnostics for blocking operation detection (Issue #279).
5
- #
6
- # Provides debug dump utilities that can be called from an IRB / Rails console
7
- # or in test helpers to inspect the current state of the Runtime.
8
- #
9
- # @example Enable diagnostics and print a dump
10
- # Phronomy.configure { |c| c.scheduler_debug = true }
11
- # Phronomy::Diagnostics.dump
4
+ # Developer-facing diagnostics for EventLoop and offload execution boundaries.
12
5
  module Diagnostics
13
- # Prints a formatted summary of the current Runtime state to +$stderr+
14
- # (or the supplied IO).
15
- #
16
- # Includes:
17
- # - BlockingAdapterPool: active workers, queue depth, abandoned count
18
- # - EventLoop: last / max / average lag in milliseconds
19
- #
20
- # @param out [IO] output destination (default: $stderr)
21
- # @return [void]
22
- # @api public
23
6
  def self.dump(out: $stderr)
24
7
  snap = Phronomy::Metrics.snapshot
25
8
 
26
9
  out.puts "[Phronomy::Diagnostics] Runtime state dump"
27
- out.puts " BlockingAdapterPool:"
28
- out.puts " pool_size : #{snap[:blocking_pool_size]}"
29
- out.puts " active_count : #{snap[:blocking_pool_active]}"
30
- out.puts " queue_depth : #{snap[:blocking_pool_queue_length]}"
31
- out.puts " abandoned_total : #{snap[:blocking_pool_abandoned_total]}"
10
+ out.puts " OffloadPool:"
11
+ out.puts " pool_size : #{snap[:offload_pool_size]}"
12
+ out.puts " active_count : #{snap[:offload_pool_active]}"
13
+ out.puts " queue_depth : #{snap[:offload_pool_queue_length]}"
14
+ out.puts " abandoned_active : #{snap[:offload_pool_abandoned_active]}"
15
+ out.puts " abandoned_total : #{snap[:offload_pool_abandoned_total]}"
32
16
  out.puts " EventLoop:"
33
- out.puts " last_lag_ms : #{snap[:event_loop_lag_last_ms]}"
34
- out.puts " max_lag_ms : #{snap[:event_loop_lag_max_ms]}"
35
- out.puts " average_lag_ms : #{snap[:event_loop_lag_average_ms]}"
17
+ out.puts " last_lag_ms : #{snap[:event_loop_lag_last_ms]}"
18
+ out.puts " max_lag_ms : #{snap[:event_loop_lag_max_ms]}"
19
+ out.puts " average_lag_ms : #{snap[:event_loop_lag_average_ms]}"
36
20
  end
37
21
 
38
- # Returns the diagnostics state as a plain Hash (useful for JSON export).
39
- #
40
- # @return [Hash]
41
- # @api public
42
22
  def self.snapshot
43
23
  Phronomy::Metrics.snapshot
44
24
  end
45
25
 
46
- # Raises an error if +invoke+ (blocking) is called from inside an EventLoop
47
- # action, preventing accidental scheduler stalls.
48
- #
49
- # Called by Agent::Base#invoke and Workflow#invoke before executing.
50
- #
51
- # @raise [Phronomy::SchedulerReentrancyError] when called from EventLoop thread
52
- # @return [void]
53
- # @api private
54
26
  def self.assert_not_in_event_loop!
55
27
  return unless Phronomy::Runtime.in_event_loop_context?
56
28
 
57
- raise Phronomy::SchedulerReentrancyError,
58
- "Blocking invoke called from inside an EventLoop action. " \
59
- "Use invoke_async instead."
29
+ raise Phronomy::EventLoopReentrancyError,
30
+ "Blocking invoke called from inside an EventLoop action. Use invoke_async instead."
60
31
  end
61
32
  end
62
33
  end
@@ -2,219 +2,36 @@
2
2
 
3
3
  module Phronomy
4
4
  module Concurrency
5
- # A thread-safe FIFO queue for passing values between concurrent tasks.
5
+ # Thread-safe FIFO queue used at explicit thread boundaries.
6
6
  #
7
- # Wraps +Thread::Queue+ so that callers do not need to reference the Ruby
8
- # standard-library type directly. A future implementation may replace the
9
- # backing primitive without changing call sites.
10
- #
11
- # @example Producer / consumer
12
- # queue = Phronomy::Concurrency::AsyncQueue.new
13
- # Runtime.instance.spawn { queue.push(expensive_io()) }
14
- # value = queue.pop # blocks until the producer pushes
15
- # @api private
7
+ # It intentionally contains no scheduler/Fiber semantics. Cooperative
8
+ # application execution is represented by FSMSession state and EventLoop events.
16
9
  class AsyncQueue
17
- # @param max_size [Integer, nil] optional upper bound on queue depth.
18
- # When set, {#push} blocks the caller until a slot is available.
19
- # @api private
20
10
  def initialize(max_size: nil)
21
11
  @queue = max_size ? SizedQueue.new(max_size) : Thread::Queue.new
22
- @max_size = max_size
23
- @waiter_mutex = Mutex.new
24
- @cross_thread_waiter = nil # [fiber, scheduler] set by _pop_cooperative; consumed by push
25
- @cross_thread_scheduler = nil # set by expect_cross_thread_push
26
12
  end
27
13
 
28
- # Enqueues +item+.
29
- # In a cooperative scheduler context with a bounded queue (max_size:), suspends
30
- # the current Fiber via a scheduler signal when the queue is full rather than
31
- # blocking the OS thread. Without a scheduler, falls back to the standard
32
- # SizedQueue blocking behaviour.
33
- # @param item [Object] value to enqueue
34
- # @return [self]
35
- # @api private
36
14
  def push(item)
37
- scheduler = Phronomy::Runtime::Scheduler.current
38
- if scheduler && @max_size
39
- _push_cooperative(scheduler, item)
40
- else
41
- @queue.push(item)
42
- scheduler.raise_signal(@coop_signal) if scheduler && @coop_signal
43
- # Wake a cross-thread waiter if one is registered.
44
- # Handles the case where a DeterministicScheduler Fiber is suspended
45
- # in _pop_cooperative waiting for a push from a non-scheduler thread
46
- # (e.g. EventLoop thread where Scheduler.current is nil).
47
- # enqueue_fiber is thread-safe; complete_blocking_await decrements
48
- # @pending_awaits so run_until_idle can eventually exit.
49
- if @cross_thread_scheduler
50
- waiter = @waiter_mutex.synchronize do
51
- w = @cross_thread_waiter
52
- @cross_thread_waiter = nil
53
- w
54
- end
55
- if waiter
56
- fiber, sched = waiter
57
- sched.complete_blocking_await
58
- sched.enqueue_fiber(-> { fiber.resume })
59
- end
60
- end
61
- end
15
+ @queue.push(item)
62
16
  self
63
17
  end
64
18
 
65
- # Dequeues and returns the next item.
66
- # In a cooperative scheduler context, suspends the current Fiber (yielding
67
- # control back to the scheduler) rather than blocking the OS thread.
68
- #
69
- # When +timeout+ is given the semantics depend on the active backend:
70
- #
71
- # * **Thread backend** (`:thread`) — uses real wall-clock time via
72
- # +Thread::Queue#pop(timeout:)+. Requires Ruby 3.2+.
73
- # Returns +nil+ if no item arrives within the specified number of real seconds.
74
- # * **DeterministicScheduler / `:fiber` backend** — uses the scheduler's
75
- # *virtual time* (+scheduler.virtual_time+). The timeout elapses only when
76
- # the virtual clock is advanced (e.g. via {Phronomy::Testing::FakeClock#advance}).
77
- # In tests this means the timeout is fully deterministic and does not depend on
78
- # actual elapsed wall time. However, in production `:fiber` mode the timeout
79
- # may never expire unless the scheduler explicitly advances virtual time.
80
- #
81
- # @note The `:fiber` backend is **EXPERIMENTAL**. Real-time timeout behaviour
82
- # in production workloads is not guaranteed and may differ from wall-clock
83
- # expectations.
84
- # @note **Cooperative timeout limitation**: on the cooperative path, the
85
- # deadline is re-checked *after* a wake-up signal arrives. If virtual time
86
- # has already passed the deadline when the consumer is woken by a producer
87
- # push, the consumer returns +nil+ rather than the pushed item. Without any
88
- # wake-up signal the waiting Fiber remains suspended even after
89
- # +scheduler.advance+ — the timeout does not self-fire.
90
- # @param timeout [Numeric, nil] seconds to wait before returning +nil+.
91
- # Semantics are wall-clock on `:thread` and virtual-time on `:fiber`.
92
- # @return [Object, nil] the next item, or +nil+ when timeout expires
93
- # @api private
94
19
  def pop(timeout: nil)
95
- scheduler = Phronomy::Runtime::Scheduler.current
96
- if scheduler
97
- _pop_cooperative(scheduler, timeout: timeout)
98
- elsif timeout
99
- @queue.pop(timeout: timeout)
100
- else
101
- @queue.pop
102
- end
20
+ timeout ? @queue.pop(timeout: timeout) : @queue.pop
103
21
  end
104
22
 
105
- # Returns the current number of items in the queue.
106
- # @return [Integer]
107
- # @api private
108
23
  def size
109
24
  @queue.size
110
25
  end
111
26
 
112
- # Returns +true+ when the queue contains no items.
113
- # @return [Boolean]
114
- # @api private
115
27
  def empty?
116
28
  @queue.empty?
117
29
  end
118
30
 
119
- # Closes the queue. Subsequent {#pop} calls raise +ClosedQueueError+.
120
- # @return [self]
121
- # @api private
122
31
  def close
123
32
  @queue.close
124
33
  self
125
34
  end
126
-
127
- # Marks this queue as expecting pushes from a non-scheduler OS thread.
128
- # When set, {#pop} in cooperative mode uses +track_blocking_await+ so that
129
- # {Runtime::DeterministicScheduler#run_until_idle} does not exit while
130
- # waiting for the cross-thread push. Called by {EventLoop#register} when
131
- # a cooperative scheduler is active on the calling thread.
132
- # @param scheduler [Runtime::Scheduler]
133
- # @return [self]
134
- # @api private
135
- def expect_cross_thread_push(scheduler)
136
- @cross_thread_scheduler = scheduler
137
- self
138
- end
139
-
140
- private
141
-
142
- # Cooperative pop for DeterministicScheduler context.
143
- # Suspends the current Fiber via the scheduler's signal mechanism rather than
144
- # blocking the OS thread.
145
- #
146
- # Two suspension paths:
147
- # * **Same-scheduler** (default): uses {CoopSignal} — the producer is another
148
- # Fiber on the same DeterministicScheduler. run_until_idle is allowed to
149
- # exit; the producer's push will enqueue the consumer Fiber.
150
- # * **Cross-thread** ({#expect_cross_thread_push} was called): uses
151
- # +track_blocking_await+ so that run_until_idle does not exit while waiting
152
- # for a push from a non-scheduler OS thread (e.g. EventLoop). The push
153
- # side calls +complete_blocking_await+ + +enqueue_fiber+ to resume.
154
- #
155
- # The empty?/register pair for the cross-thread path is wrapped in
156
- # @waiter_mutex to eliminate the race between the empty check and the
157
- # registration of the waker.
158
- # @api private
159
- # @param scheduler [Runtime::Scheduler]
160
- # @param timeout [Numeric, nil]
161
- # @return [Object, nil]
162
- def _pop_cooperative(scheduler, timeout:)
163
- @coop_signal ||= scheduler.new_signal
164
- deadline = timeout ? (scheduler.virtual_time + timeout) : nil
165
-
166
- loop do
167
- unless @queue.empty?
168
- item = @queue.pop(timeout: 0)
169
- # Notify a push-waiter (bounded queue) that a slot opened up.
170
- scheduler.raise_signal(@push_signal) if @push_signal
171
- return item
172
- end
173
- return nil if deadline && scheduler.virtual_time >= deadline
174
-
175
- if @cross_thread_scheduler
176
- # Cross-thread path: atomically check the queue and register a waker
177
- # so that a concurrent push cannot slip between the empty? check above
178
- # and the registration below.
179
- will_yield = false
180
- @waiter_mutex.synchronize do
181
- if @queue.empty?
182
- @cross_thread_waiter = [Fiber.current, scheduler]
183
- scheduler.track_blocking_await
184
- will_yield = true
185
- end
186
- # else: push arrived between the loop's empty? check and here;
187
- # will_yield stays false and the next loop iteration dequeues it.
188
- end
189
- Fiber.yield(:cooperative_suspend) if will_yield
190
- else
191
- scheduler.wait_for_signal(@coop_signal)
192
- end
193
-
194
- return nil if deadline && scheduler.virtual_time >= deadline
195
- end
196
- end
197
-
198
- # Cooperative push for DeterministicScheduler context with a bounded queue.
199
- # Suspends the current Fiber via a scheduler signal when the queue is full,
200
- # rather than blocking the OS thread.
201
- # @api private
202
- # @param scheduler [Runtime::Scheduler]
203
- # @param item [Object]
204
- # @return [void]
205
- def _push_cooperative(scheduler, item)
206
- @push_signal ||= scheduler.new_signal
207
-
208
- loop do
209
- unless @queue.size >= @max_size
210
- @queue.push(item)
211
- # Notify any pop-waiter that an item is now available.
212
- scheduler.raise_signal(@coop_signal) if @coop_signal
213
- return
214
- end
215
- scheduler.wait_for_signal(@push_signal)
216
- end
217
- end
218
35
  end
219
36
  end
220
37
  end
@@ -6,7 +6,7 @@ module Phronomy
6
6
  # optionally a {Deadline}.
7
7
  #
8
8
  # +CancellationScope+ replaces ad-hoc +Timeout.timeout+ calls in agent and
9
- # tool code. All work performed within a scope should observe the scope's
9
+ # tool code. All work performed within a scope should observe the scope's
10
10
  # token; when the scope is cancelled (explicitly or by deadline expiry) the
11
11
  # token is cancelled and all child tasks that check it will stop.
12
12
  #
@@ -18,10 +18,9 @@ module Phronomy
18
18
  #
19
19
  # @example Explicit cancellation
20
20
  # scope = Phronomy::Concurrency::CancellationScope.new
21
- # Phronomy::Runtime.instance.spawn(name: "worker") do
22
- # scope.token.raise_if_cancelled!
23
- # # ... do work ...
24
- # end
21
+ # operation = Phronomy::Runtime.instance.offload.submit(
22
+ # cancellation_token: scope.token
23
+ # ) { synchronous_operation }
25
24
  # scope.cancel! if some_condition
26
25
  class CancellationScope
27
26
  # @return [CancellationToken] the token owned by this scope
@@ -33,7 +32,7 @@ module Phronomy
33
32
  # @param parent_token [CancellationToken, nil] when provided, cancellation of
34
33
  # the parent token is propagated to this scope's token via a callback
35
34
  # (for explicit cancel) and/or the Runtime timer queue (for monotonic
36
- # deadline expiry). No polling thread is spawned.
35
+ # deadline expiry). No polling thread is spawned.
37
36
  # @api private
38
37
  def initialize(parent_token: nil)
39
38
  @token = Phronomy::Concurrency::CancellationToken.new
@@ -92,7 +91,7 @@ module Phronomy
92
91
  end
93
92
 
94
93
  # Pops from +queue+ with a timeout derived from the attached deadline (or
95
- # +fallback_timeout+ seconds when no deadline is set). If the pop times out,
94
+ # +fallback_timeout+ seconds when no deadline is set). If the pop times out,
96
95
  # the scope is cancelled and the block is called (or a {TimeoutError} raised).
97
96
  #
98
97
  # @param queue [Phronomy::Concurrency::AsyncQueue] the queue to pop from
@@ -28,8 +28,20 @@ module Phronomy
28
28
  [remaining, 0.0].max
29
29
  end
30
30
 
31
+ # Registers a callback for explicit cancellation.
32
+ #
33
+ # Deadline expiry by itself only changes {#cancelled?}. Components that need
34
+ # callback delivery for a monotonic deadline must promote that deadline to
35
+ # +cancel!+ through the Runtime timer queue.
36
+ #
37
+ # Cancellation callbacks are independent notifications. Failure of one
38
+ # callback is logged and does not suppress delivery to other callbacks.
39
+ #
40
+ # @return [self]
31
41
  # @api public
32
42
  def on_cancel(&block)
43
+ raise ArgumentError, "on_cancel requires a block" unless block
44
+
33
45
  already_cancelled = @mutex.synchronize do
34
46
  if @cancelled
35
47
  true
@@ -38,19 +50,29 @@ module Phronomy
38
50
  false
39
51
  end
40
52
  end
41
- block.call if already_cancelled
53
+ deliver_cancel_callback(block) if already_cancelled
42
54
  self
43
55
  end
44
56
 
57
+ # Explicitly cancels the token and invokes each currently registered callback
58
+ # once. The callback registry is cleared before callbacks run so completed
59
+ # registrations are not retained for the lifetime of a long-lived token.
60
+ #
61
+ # Cancellation callbacks are isolated from one another: a StandardError raised
62
+ # by one callback is logged and the remaining callbacks are still delivered.
63
+ #
64
+ # @return [self]
45
65
  # @api public
46
66
  def cancel!
47
67
  callbacks = @mutex.synchronize do
48
68
  return self if @cancelled
49
69
 
50
70
  @cancelled = true
51
- @cancel_callbacks.dup
71
+ callbacks = @cancel_callbacks
72
+ @cancel_callbacks = []
73
+ callbacks
52
74
  end
53
- callbacks.each(&:call)
75
+ callbacks.each { |callback| deliver_cancel_callback(callback) }
54
76
  self
55
77
  end
56
78
 
@@ -66,6 +88,29 @@ module Phronomy
66
88
  def raise_if_cancelled!(message = "invocation cancelled")
67
89
  raise Phronomy::CancellationError, message if cancelled?
68
90
  end
91
+
92
+ private
93
+
94
+ def deliver_cancel_callback(callback)
95
+ callback.call
96
+ rescue => error
97
+ Phronomy.configuration.logger&.error do
98
+ "[CancellationToken] on_cancel callback raised #{error.class}: #{error.message}"
99
+ end
100
+ end
101
+
102
+ # Removes an explicit-cancellation callback that is no longer needed.
103
+ #
104
+ # This exists so framework operation registrations do not keep their captured
105
+ # state alive when the operation completes before the token is cancelled. It is
106
+ # intentionally not part of the public cancellation API.
107
+ #
108
+ # A concurrent +cancel!+ may already have taken the callback for delivery;
109
+ # callers must therefore make their callback idempotent.
110
+ def unregister_cancel_callback(callback)
111
+ @mutex.synchronize { @cancel_callbacks.delete(callback) }
112
+ self
113
+ end
69
114
  end
70
115
  end
71
116
  end
@@ -44,9 +44,8 @@ module Phronomy
44
44
  end
45
45
 
46
46
  # Attaches this deadline to a {CancellationToken} by cancelling the token
47
- # when the deadline expires. Uses the Runtime timer queue (a single
48
- # background thread shared by all deadlines) instead of spawning one thread
49
- # per deadline.
47
+ # when the deadline expires. The Runtime timer queue is driven by EventLoop;
48
+ # no timer-specific OS thread is created.
50
49
  #
51
50
  # @param token [CancellationToken]
52
51
  # @param timer_queue [Runtime::TimerQueue, nil] queue to register with;