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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +122 -1134
- data/README.md +70 -1280
- data/benchmark/bench_regression.rb +25 -5
- data/docs/changelog/0.14-and-earlier.md +1137 -0
- data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
- data/docs/decisions/009-state-store-abstraction.md +1 -1
- data/docs/decisions/010-cooperative-first-concurrency.md +155 -235
- data/docs/decisions/014-unified-persistence-durable-state.md +268 -0
- data/docs/features.md +93 -0
- data/docs/getting-started.md +387 -0
- data/docs/migrations/0.15.md +35 -0
- data/docs/migrations/0.16.md +43 -0
- data/docs/migrations/0.19.md +148 -0
- data/docs/runtime-and-concurrency.md +349 -0
- data/examples/workflows/generic_task_event_mapping.rb +14 -6
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +2 -2
- data/lib/phronomy/agent/async_event_api.rb +3 -3
- data/lib/phronomy/agent/base.rb +116 -55
- data/lib/phronomy/agent/context/capability/base.rb +13 -3
- data/lib/phronomy/agent/context_assembler.rb +13 -3
- data/lib/phronomy/agent/execution_coordinator.rb +423 -252
- data/lib/phronomy/agent/journal_projection.rb +5 -1
- data/lib/phronomy/agent/shared_state.rb +2 -0
- data/lib/phronomy/agent/tool_executor.rb +29 -71
- data/lib/phronomy/agent/tool_invocation.rb +97 -47
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
- data/lib/phronomy/configuration.rb +7 -30
- data/lib/phronomy/diagnostics.rb +12 -41
- data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
- data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +48 -3
- data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
- data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
- data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
- data/lib/phronomy/engine/event_loop.rb +170 -193
- data/lib/phronomy/engine/fsm_session.rb +6 -4
- data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
- data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
- data/lib/phronomy/engine/runtime.rb +52 -158
- data/lib/phronomy/engine/task.rb +136 -277
- data/lib/phronomy/llm_adapter/base.rb +14 -14
- data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
- data/lib/phronomy/llm_adapter.rb +2 -2
- data/lib/phronomy/metrics.rb +15 -30
- data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
- data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
- data/lib/phronomy/multi_agent/handoff.rb +1 -0
- data/lib/phronomy/multi_agent/orchestrator.rb +147 -99
- data/lib/phronomy/multi_agent/team_coordinator.rb +2 -0
- data/lib/phronomy/persistence/in_memory.rb +113 -8
- data/lib/phronomy/persistence.rb +12 -3
- data/lib/phronomy/testing/eval/comparison.rb +23 -0
- data/lib/phronomy/testing/eval/dataset.rb +27 -0
- data/lib/phronomy/testing/eval/eval_case.rb +13 -0
- data/lib/phronomy/testing/eval/eval_result.rb +16 -0
- data/lib/phronomy/testing/eval/metrics.rb +43 -0
- data/lib/phronomy/testing/eval/runner.rb +52 -0
- data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
- data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
- data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
- data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
- data/lib/phronomy/testing/eval/scorer.rb +10 -0
- data/lib/phronomy/testing/eval.rb +9 -0
- data/lib/phronomy/testing/fake_clock.rb +6 -53
- data/lib/phronomy/testing.rb +2 -6
- data/lib/phronomy/tools/agent.rb +141 -6
- data/lib/phronomy/vector_store/async_backend.rb +21 -17
- data/lib/phronomy/vector_store/base.rb +2 -2
- data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +10 -9
- data/lib/phronomy/workflow_runner.rb +363 -99
- data/lib/phronomy.rb +10 -4
- data/scripts/api_snapshot.rb +4 -4
- metadata +25 -33
- data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
- data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
- data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
- data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
- data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
- data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
- data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
- data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
- data/lib/phronomy/engine/task/backend.rb +0 -80
- data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
- data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
- data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
- data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
- data/lib/phronomy/engine/task/thread_backend.rb +0 -84
- data/lib/phronomy/engine/task_group.rb +0 -193
- data/lib/phronomy/eval/comparison.rb +0 -47
- data/lib/phronomy/eval/dataset.rb +0 -45
- data/lib/phronomy/eval/eval_case.rb +0 -17
- data/lib/phronomy/eval/eval_result.rb +0 -29
- data/lib/phronomy/eval/metrics.rb +0 -66
- data/lib/phronomy/eval/runner.rb +0 -94
- data/lib/phronomy/eval/scorer/base.rb +0 -22
- data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
- data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
- data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
- data/lib/phronomy/eval/scorer.rb +0 -9
- data/lib/phronomy/eval.rb +0 -7
- data/lib/phronomy/state_store/base.rb +0 -48
- data/lib/phronomy/state_store/in_memory.rb +0 -62
- data/lib/phronomy/testing/fake_scheduler.rb +0 -104
- 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
|
-
|
|
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 :
|
|
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 :
|
|
30
|
-
attr_accessor :
|
|
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
|
-
@
|
|
73
|
-
@
|
|
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
|
-
@
|
|
78
|
-
@runtime_backend = :thread
|
|
79
|
-
@strict_runtime_guards = false
|
|
56
|
+
@persistence = nil
|
|
80
57
|
end
|
|
81
58
|
end
|
|
82
59
|
end
|
data/lib/phronomy/diagnostics.rb
CHANGED
|
@@ -1,62 +1,33 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
|
-
# Developer-facing diagnostics for
|
|
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 "
|
|
28
|
-
out.puts " pool_size
|
|
29
|
-
out.puts " active_count
|
|
30
|
-
out.puts " queue_depth
|
|
31
|
-
out.puts "
|
|
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
|
|
34
|
-
out.puts " max_lag_ms
|
|
35
|
-
out.puts " average_lag_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::
|
|
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
|
-
#
|
|
5
|
+
# Thread-safe FIFO queue used at explicit thread boundaries.
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
22
|
-
# scope.token
|
|
23
|
-
#
|
|
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).
|
|
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).
|
|
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
|
|
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
|
|
71
|
+
callbacks = @cancel_callbacks
|
|
72
|
+
@cancel_callbacks = []
|
|
73
|
+
callbacks
|
|
52
74
|
end
|
|
53
|
-
callbacks.each(
|
|
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.
|
|
48
|
-
#
|
|
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;
|