phronomy 0.7.0 → 0.7.1
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/.mutant.yml +8 -7
- data/CHANGELOG.md +151 -1
- data/README.md +155 -32
- data/Rakefile +33 -0
- data/benchmark/baseline.json +1 -1
- data/benchmark/bench_regression.rb +1 -0
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
- data/docs/decisions/006-no-built-in-guardrails.md +20 -2
- data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
- data/lib/phronomy/agent/base.rb +250 -65
- data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
- data/lib/phronomy/agent/fsm.rb +41 -64
- data/lib/phronomy/agent/orchestrator.rb +146 -121
- data/lib/phronomy/agent/parallel_tool_chat.rb +79 -22
- data/lib/phronomy/agent/react_agent.rb +8 -0
- data/lib/phronomy/async_queue.rb +155 -0
- data/lib/phronomy/blocking_adapter_pool.rb +435 -0
- data/lib/phronomy/cancellation_scope.rb +123 -0
- data/lib/phronomy/cancellation_token.rb +43 -2
- data/lib/phronomy/concurrency_gate.rb +155 -0
- data/lib/phronomy/configuration.rb +142 -0
- data/lib/phronomy/deadline.rb +63 -0
- data/lib/phronomy/diagnostics.rb +62 -0
- data/lib/phronomy/embeddings/base.rb +17 -0
- data/lib/phronomy/eval/runner.rb +9 -9
- data/lib/phronomy/event_loop.rb +181 -43
- data/lib/phronomy/fsm_session.rb +50 -4
- data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
- data/lib/phronomy/invocation_context.rb +152 -0
- data/lib/phronomy/knowledge_source/base.rb +18 -0
- data/lib/phronomy/llm_adapter/base.rb +104 -0
- data/lib/phronomy/llm_adapter/ruby_llm.rb +41 -0
- data/lib/phronomy/llm_adapter.rb +20 -0
- data/lib/phronomy/metrics.rb +38 -0
- data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
- data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
- data/lib/phronomy/runtime/gate_registry.rb +52 -0
- data/lib/phronomy/runtime/pool_registry.rb +57 -0
- data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
- data/lib/phronomy/runtime/scheduler.rb +98 -0
- data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
- data/lib/phronomy/runtime/task_registry.rb +48 -0
- data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
- data/lib/phronomy/runtime/timer_queue.rb +106 -0
- data/lib/phronomy/runtime/timer_service.rb +42 -0
- data/lib/phronomy/runtime.rb +374 -0
- data/lib/phronomy/task/backend.rb +80 -0
- data/lib/phronomy/task/fiber_backend.rb +157 -0
- data/lib/phronomy/task/immediate_backend.rb +89 -0
- data/lib/phronomy/task/thread_backend.rb +84 -0
- data/lib/phronomy/task.rb +275 -0
- data/lib/phronomy/task_group.rb +265 -0
- data/lib/phronomy/testing/fake_clock.rb +109 -0
- data/lib/phronomy/testing/fake_scheduler.rb +104 -0
- data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
- data/lib/phronomy/testing.rb +12 -0
- data/lib/phronomy/tool/base.rb +110 -2
- data/lib/phronomy/tool/mcp_tool.rb +47 -16
- data/lib/phronomy/tool/scope_policy.rb +50 -0
- data/lib/phronomy/tool_executor.rb +106 -0
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
- data/lib/phronomy/vector_store/async_backend.rb +110 -0
- data/lib/phronomy/vector_store/base.rb +7 -0
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +52 -5
- data/lib/phronomy/workflow_context.rb +29 -2
- data/lib/phronomy/workflow_runner.rb +74 -3
- data/lib/phronomy.rb +42 -0
- metadata +40 -2
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
class Runtime
|
|
5
|
+
# Synchronous scheduler for use in tests.
|
|
6
|
+
#
|
|
7
|
+
# Each spawned task is executed immediately on the calling thread using
|
|
8
|
+
# {Task::ImmediateBackend}. No new threads are created, so a
|
|
9
|
+
# {Runtime} that uses +FakeScheduler+ does not increase the process
|
|
10
|
+
# Thread count on {Runtime#spawn}.
|
|
11
|
+
#
|
|
12
|
+
# In addition to the basic synchronous execution, +FakeScheduler+ records
|
|
13
|
+
# all task lifecycle events in {#event_log} and all spawned tasks in
|
|
14
|
+
# {#tasks}. This allows specs to assert event ordering and task state
|
|
15
|
+
# without relying on wall-clock sleeps.
|
|
16
|
+
#
|
|
17
|
+
# === tick / tick_until
|
|
18
|
+
#
|
|
19
|
+
# Because +FakeScheduler+ uses {Task::ImmediateBackend}, every task runs
|
|
20
|
+
# to completion before {Runtime#spawn} returns. Consequently {#tick} is
|
|
21
|
+
# semantically a no-op -- the "ready task" has already executed. It is
|
|
22
|
+
# provided so that test code written against the cooperative scheduler
|
|
23
|
+
# interface compiles and documents intent (e.g. "advance by one step").
|
|
24
|
+
#
|
|
25
|
+
# === pending_timers
|
|
26
|
+
#
|
|
27
|
+
# If a {Phronomy::Testing::FakeClock} is injected via {#clock=}, its
|
|
28
|
+
# pending callbacks are surfaced as +pending_timers+.
|
|
29
|
+
#
|
|
30
|
+
# @example
|
|
31
|
+
# runtime = Phronomy::Runtime.new(scheduler: Phronomy::Runtime::FakeScheduler.new)
|
|
32
|
+
# task = runtime.spawn(name: "agent-test") { 42 }
|
|
33
|
+
# expect(task.await).to eq(42)
|
|
34
|
+
# expect(task.status).to eq(:completed)
|
|
35
|
+
# @api private
|
|
36
|
+
class FakeScheduler < Scheduler
|
|
37
|
+
# @return [Array<Hash>] ordered list of task lifecycle events.
|
|
38
|
+
# Each entry is +{ type:, task_name:, at: }+ where +type+ is one of
|
|
39
|
+
# +:spawned+, +:started+, +:completed+, +:cancelled+, +:failed+ and
|
|
40
|
+
# +at+ is a Float monotonic timestamp (seconds).
|
|
41
|
+
attr_reader :event_log
|
|
42
|
+
|
|
43
|
+
# @return [Array<Hash>] all tasks spawned by this scheduler.
|
|
44
|
+
# Each entry is +{ task:, name:, status: }+.
|
|
45
|
+
attr_reader :tasks
|
|
46
|
+
|
|
47
|
+
# Optional {Phronomy::Testing::FakeClock} used to timestamp events and
|
|
48
|
+
# surface pending timers. When +nil+, a real monotonic clock is used.
|
|
49
|
+
# @return [Phronomy::Testing::FakeClock, nil]
|
|
50
|
+
attr_accessor :clock
|
|
51
|
+
|
|
52
|
+
def initialize
|
|
53
|
+
@event_log = []
|
|
54
|
+
@tasks = []
|
|
55
|
+
@clock = nil
|
|
56
|
+
@mutex = Mutex.new
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Spawns +block+ as a {Task} backed by {Task::ImmediateBackend}.
|
|
60
|
+
# The block executes synchronously before this method returns.
|
|
61
|
+
# Lifecycle events are recorded in {#event_log}.
|
|
62
|
+
#
|
|
63
|
+
# @param name [String, nil]
|
|
64
|
+
# @param parent [Task, nil]
|
|
65
|
+
# @return [Task]
|
|
66
|
+
# @api private
|
|
67
|
+
def spawn(name:, parent:, &block)
|
|
68
|
+
_log_event(:spawned, name)
|
|
69
|
+
task = Task.spawn(name: name, parent: parent, backend_class: Task::ImmediateBackend) do
|
|
70
|
+
_log_event(:started, name)
|
|
71
|
+
begin
|
|
72
|
+
result = block.call
|
|
73
|
+
_log_event(:completed, name)
|
|
74
|
+
result
|
|
75
|
+
rescue CancellationError
|
|
76
|
+
_log_event(:cancelled, name)
|
|
77
|
+
raise
|
|
78
|
+
rescue => e
|
|
79
|
+
_log_event(:failed, name)
|
|
80
|
+
raise e
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
@mutex.synchronize { @tasks << {task: task, name: name, status: task.status} }
|
|
84
|
+
task
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Execute one ready task.
|
|
88
|
+
#
|
|
89
|
+
# Because {Task::ImmediateBackend} runs tasks synchronously inside
|
|
90
|
+
# {#spawn}, all ready tasks have already executed by the time this
|
|
91
|
+
# method is called. This method is a no-op provided for API
|
|
92
|
+
# compatibility with cooperative scheduler interfaces.
|
|
93
|
+
#
|
|
94
|
+
# @return [self]
|
|
95
|
+
# @api private
|
|
96
|
+
def tick
|
|
97
|
+
self
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Run +block+ repeatedly until it returns truthy or +max_ticks+ is
|
|
101
|
+
# reached. Because tasks execute synchronously, the condition is
|
|
102
|
+
# evaluated once; if it is already met this method returns immediately.
|
|
103
|
+
#
|
|
104
|
+
# @param max_ticks [Integer] safety bound (default: 1000)
|
|
105
|
+
# @yield condition evaluated after each tick
|
|
106
|
+
# @return [Boolean] +true+ if condition was satisfied
|
|
107
|
+
# @api private
|
|
108
|
+
def tick_until(max_ticks: 1000)
|
|
109
|
+
max_ticks.times do
|
|
110
|
+
return true if yield
|
|
111
|
+
tick
|
|
112
|
+
end
|
|
113
|
+
yield ? true : false
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Returns a list of pending timer entries surfaced from the injected
|
|
117
|
+
# {clock}. Returns an empty array when no clock is set.
|
|
118
|
+
#
|
|
119
|
+
# @return [Array<Hash>] each entry: +{ fire_at:, description: }+
|
|
120
|
+
# @api private
|
|
121
|
+
def pending_timers
|
|
122
|
+
return [] unless @clock
|
|
123
|
+
|
|
124
|
+
@clock.pending_timer_entries
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Assert that the named tasks completed in the given order.
|
|
128
|
+
# Raises +RSpec::Expectations::ExpectationNotMetError+ if order is wrong.
|
|
129
|
+
# Intended for use inside RSpec examples.
|
|
130
|
+
#
|
|
131
|
+
# @param names [Array<String, nil>] task names in expected order
|
|
132
|
+
# @return [void]
|
|
133
|
+
# @api private
|
|
134
|
+
def assert_order(*names)
|
|
135
|
+
completed = @event_log.select { |e| e[:type] == :completed }.map { |e| e[:task_name] }
|
|
136
|
+
indices = names.map { |n| completed.index(n) }
|
|
137
|
+
unless indices.none?(&:nil?) && indices == indices.sort
|
|
138
|
+
raise RSpec::Expectations::ExpectationNotMetError,
|
|
139
|
+
"Expected tasks to complete in order #{names.inspect} " + "but completed order was #{completed.inspect}"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Assert that the named tasks reached +:cancelled+ state.
|
|
144
|
+
#
|
|
145
|
+
# @param names [Array<String, nil>] task names expected to be cancelled
|
|
146
|
+
# @return [void]
|
|
147
|
+
# @api private
|
|
148
|
+
def assert_cancelled(*names)
|
|
149
|
+
cancelled = @event_log.select { |e| e[:type] == :cancelled }.map { |e| e[:task_name] }
|
|
150
|
+
missing = names.reject { |n| cancelled.include?(n) }
|
|
151
|
+
return if missing.empty?
|
|
152
|
+
|
|
153
|
+
raise RSpec::Expectations::ExpectationNotMetError,
|
|
154
|
+
"Expected tasks #{missing.inspect} to be cancelled " + "but cancelled tasks were #{cancelled.inspect}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def _log_event(type, task_name)
|
|
160
|
+
at = @clock ? @clock.now : Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
161
|
+
@mutex.synchronize { @event_log << {type: type, task_name: task_name, at: at} }
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
class Runtime
|
|
5
|
+
# Lazy cache of {ConcurrencyGate} instances, keyed by resource name.
|
|
6
|
+
#
|
|
7
|
+
# Gate concurrency caps are read from {Phronomy::Configuration} when a gate
|
|
8
|
+
# is first accessed; subsequent calls return the cached instance. Call
|
|
9
|
+
# {#reset} to drop the cache and force a rebuild on the next access.
|
|
10
|
+
# @api private
|
|
11
|
+
class GateRegistry
|
|
12
|
+
GATE_CONFIG_MAP = {
|
|
13
|
+
agent: :max_concurrent_agent_tasks,
|
|
14
|
+
tool: :max_concurrent_tool_tasks,
|
|
15
|
+
workflow: :max_concurrent_workflow_tasks,
|
|
16
|
+
llm: :max_concurrent_llm_calls,
|
|
17
|
+
rag: :max_concurrent_rag_fetches,
|
|
18
|
+
vector: :max_concurrent_vector_searches
|
|
19
|
+
}.freeze
|
|
20
|
+
private_constant :GATE_CONFIG_MAP
|
|
21
|
+
|
|
22
|
+
def initialize
|
|
23
|
+
@mutex = Mutex.new
|
|
24
|
+
@gates = {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Returns (or lazily creates) the gate for +name+.
|
|
28
|
+
# @param name [Symbol]
|
|
29
|
+
# @return [ConcurrencyGate]
|
|
30
|
+
# @api private
|
|
31
|
+
def get(name)
|
|
32
|
+
@mutex.synchronize { @gates[name] ||= _build(name) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Drops the cached gate for +name+ so the next {#get} rebuilds it.
|
|
36
|
+
# @param name [Symbol]
|
|
37
|
+
# @return [void]
|
|
38
|
+
# @api private
|
|
39
|
+
def reset(name)
|
|
40
|
+
@mutex.synchronize { @gates.delete(name) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def _build(name)
|
|
46
|
+
config_key = GATE_CONFIG_MAP[name]
|
|
47
|
+
max = config_key ? Phronomy.configuration.public_send(config_key) : nil
|
|
48
|
+
ConcurrencyGate.new(max_concurrent: max, name: name)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
class Runtime
|
|
5
|
+
# Registry and lifecycle manager for {BlockingAdapterPool} instances.
|
|
6
|
+
#
|
|
7
|
+
# Maintains one unnamed "default" pool (accessed via {#default_pool}) and
|
|
8
|
+
# an arbitrary number of named pools (accessed via {#named_pool}).
|
|
9
|
+
# All pools are shut down together by {#shutdown}.
|
|
10
|
+
# @api private
|
|
11
|
+
class PoolRegistry
|
|
12
|
+
def initialize
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
@pools = {}
|
|
15
|
+
@default = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Returns (or lazily creates) the unnamed default pool.
|
|
19
|
+
# @param pool_size [Integer]
|
|
20
|
+
# @param queue_size [Integer]
|
|
21
|
+
# @return [BlockingAdapterPool]
|
|
22
|
+
# @api private
|
|
23
|
+
def default_pool(pool_size: 10, queue_size: 100)
|
|
24
|
+
@default ||= BlockingAdapterPool.new(
|
|
25
|
+
name: :default,
|
|
26
|
+
pool_size: pool_size,
|
|
27
|
+
queue_size: queue_size
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns (or lazily creates) a named pool.
|
|
32
|
+
# @param name [Symbol, String]
|
|
33
|
+
# @param size [Integer]
|
|
34
|
+
# @param queue_size [Integer]
|
|
35
|
+
# @return [BlockingAdapterPool]
|
|
36
|
+
# @api private
|
|
37
|
+
def named_pool(name, size: 10, queue_size: 100)
|
|
38
|
+
@mutex.synchronize do
|
|
39
|
+
@pools[name.to_sym] ||= BlockingAdapterPool.new(
|
|
40
|
+
name: name,
|
|
41
|
+
pool_size: size,
|
|
42
|
+
queue_size: queue_size
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Shuts down the default pool and all named pools.
|
|
48
|
+
# @return [void]
|
|
49
|
+
# @api private
|
|
50
|
+
def shutdown
|
|
51
|
+
@default&.shutdown
|
|
52
|
+
pools = @mutex.synchronize { @pools.values.dup }
|
|
53
|
+
pools.each(&:shutdown)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
class Runtime
|
|
5
|
+
# Internal store for task-centric counters and latency samples.
|
|
6
|
+
#
|
|
7
|
+
# All access is mutex-protected. The ring buffers for wait/run times are
|
|
8
|
+
# bounded to {WINDOW} samples so that long-lived runtimes do not grow
|
|
9
|
+
# unbounded.
|
|
10
|
+
# @api private
|
|
11
|
+
class RuntimeMetrics
|
|
12
|
+
WINDOW = 1000
|
|
13
|
+
private_constant :WINDOW
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
@active_by_type = Hash.new(0)
|
|
18
|
+
@wait_ms = []
|
|
19
|
+
@run_ms = []
|
|
20
|
+
@cancelled = Hash.new(0)
|
|
21
|
+
@failed = Hash.new(0)
|
|
22
|
+
@starvation_count = 0
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Records that a new task of +type+ has been spawned.
|
|
26
|
+
# @param type [Symbol]
|
|
27
|
+
# @return [void]
|
|
28
|
+
# @api private
|
|
29
|
+
def record_start(type)
|
|
30
|
+
@mutex.synchronize { @active_by_type[type] += 1 }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Appends a wait-time sample (milliseconds from spawn to start).
|
|
34
|
+
# @param wait_ms [Float]
|
|
35
|
+
# @return [void]
|
|
36
|
+
# @api private
|
|
37
|
+
def record_wait(wait_ms)
|
|
38
|
+
@mutex.synchronize do
|
|
39
|
+
@wait_ms << wait_ms
|
|
40
|
+
@wait_ms.shift if @wait_ms.size > WINDOW
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Records completion of a task (decrements active count, appends run time).
|
|
45
|
+
# @param type [Symbol]
|
|
46
|
+
# @param outcome [:completed, :cancelled, :failed]
|
|
47
|
+
# @param run_start_ms [Integer] monotonic millisecond timestamp from task start
|
|
48
|
+
# @return [void]
|
|
49
|
+
# @api private
|
|
50
|
+
def record_end(type, outcome, run_start_ms)
|
|
51
|
+
run_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - run_start_ms
|
|
52
|
+
@mutex.synchronize do
|
|
53
|
+
@active_by_type[type] = [@active_by_type[type] - 1, 0].max
|
|
54
|
+
@run_ms << run_ms
|
|
55
|
+
@run_ms.shift if @run_ms.size > WINDOW
|
|
56
|
+
case outcome
|
|
57
|
+
when :cancelled then @cancelled[type] += 1
|
|
58
|
+
when :failed then @failed[type] += 1
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Increments the CPU-starvation counter (task ran without yielding over
|
|
64
|
+
# the configured +blocking_detect_threshold_ms+ threshold).
|
|
65
|
+
# @return [void]
|
|
66
|
+
# @api private
|
|
67
|
+
def increment_starvation
|
|
68
|
+
@mutex.synchronize { @starvation_count += 1 }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Returns the current starvation counter value.
|
|
72
|
+
# @return [Integer]
|
|
73
|
+
# @api private
|
|
74
|
+
def starvation_count
|
|
75
|
+
@mutex.synchronize { @starvation_count }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Returns the full task-centric metrics hash (see {Runtime#task_snapshot}).
|
|
79
|
+
# @return [Hash{Symbol => Numeric}]
|
|
80
|
+
# @api private
|
|
81
|
+
def snapshot
|
|
82
|
+
@mutex.synchronize do
|
|
83
|
+
active = @active_by_type.dup
|
|
84
|
+
wait = @wait_ms.dup
|
|
85
|
+
run = @run_ms.dup
|
|
86
|
+
cancelled = @cancelled.values.sum
|
|
87
|
+
failed = @failed.values.sum
|
|
88
|
+
starvation = @starvation_count
|
|
89
|
+
{
|
|
90
|
+
active_agent_tasks: active[:agent].to_i,
|
|
91
|
+
active_tool_tasks: active[:tool].to_i,
|
|
92
|
+
active_workflow_tasks: active[:workflow].to_i,
|
|
93
|
+
active_rag_tasks: active[:rag].to_i,
|
|
94
|
+
active_llm_tasks: active[:llm].to_i,
|
|
95
|
+
task_wait_time_p50_ms: _percentile(wait, 50),
|
|
96
|
+
task_wait_time_p95_ms: _percentile(wait, 95),
|
|
97
|
+
task_run_time_p50_ms: _percentile(run, 50),
|
|
98
|
+
task_run_time_p95_ms: _percentile(run, 95),
|
|
99
|
+
cancelled_tasks: cancelled,
|
|
100
|
+
failed_tasks: failed,
|
|
101
|
+
non_yield_threshold_violation_count: starvation
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def _percentile(samples, pct)
|
|
109
|
+
return 0.0 if samples.empty?
|
|
110
|
+
|
|
111
|
+
sorted = samples.sort
|
|
112
|
+
idx = ((pct / 100.0) * (sorted.size - 1)).round
|
|
113
|
+
sorted[idx].round(3)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
class Runtime
|
|
5
|
+
# Abstract base class for Runtime scheduler backends.
|
|
6
|
+
#
|
|
7
|
+
# A scheduler is responsible for turning +Runtime#spawn+ calls into
|
|
8
|
+
# runnable {Task} objects. Concrete subclasses decide whether tasks
|
|
9
|
+
# execute on threads, Fibers, or some other execution primitive.
|
|
10
|
+
#
|
|
11
|
+
# The interface is intentionally minimal: callers only see {Task}
|
|
12
|
+
# objects and never interact with the scheduler directly.
|
|
13
|
+
class Scheduler
|
|
14
|
+
# Thread-local key under which the active scheduler is stored.
|
|
15
|
+
# Shared with {Task::FiberBackend} (same symbol).
|
|
16
|
+
# @api private
|
|
17
|
+
SCHEDULER_KEY = :phronomy_deterministic_scheduler
|
|
18
|
+
|
|
19
|
+
# Returns the scheduler currently dispatching on this OS thread, or +nil+
|
|
20
|
+
# when running outside a cooperative (Fiber-based) scheduler context.
|
|
21
|
+
#
|
|
22
|
+
# Uses +Thread#thread_variable_get+ (not +Thread#[]+) so that the value is
|
|
23
|
+
# visible across all Fibers running on the same OS thread.
|
|
24
|
+
#
|
|
25
|
+
# @return [Scheduler, nil]
|
|
26
|
+
# @api private
|
|
27
|
+
def self.current
|
|
28
|
+
Thread.current.thread_variable_get(SCHEDULER_KEY)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Creates a new scheduler-aware signal object for this scheduler.
|
|
32
|
+
# Signalling primitives use this instead of +ConditionVariable+ when a
|
|
33
|
+
# cooperative scheduler is active.
|
|
34
|
+
#
|
|
35
|
+
# Default implementation raises +NotImplementedError+. Subclasses that
|
|
36
|
+
# support cooperative suspension (e.g. {DeterministicScheduler}) must
|
|
37
|
+
# override this.
|
|
38
|
+
#
|
|
39
|
+
# @return [Object] an opaque signal handle understood by {#wait_for_signal}
|
|
40
|
+
# and {#raise_signal}
|
|
41
|
+
# @api private
|
|
42
|
+
def new_signal
|
|
43
|
+
raise NotImplementedError, "#{self.class}#new_signal is not implemented"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Suspends the current execution unit (Fiber or Thread) until +signal+ is
|
|
47
|
+
# raised via {#raise_signal}.
|
|
48
|
+
#
|
|
49
|
+
# @param signal [Object] signal handle returned by {#new_signal}
|
|
50
|
+
# @return [void]
|
|
51
|
+
# @api private
|
|
52
|
+
def wait_for_signal(signal)
|
|
53
|
+
raise NotImplementedError, "#{self.class}#wait_for_signal is not implemented"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Wakes up one waiter suspended on +signal+.
|
|
57
|
+
#
|
|
58
|
+
# @param signal [Object] signal handle returned by {#new_signal}
|
|
59
|
+
# @return [void]
|
|
60
|
+
# @api private
|
|
61
|
+
def raise_signal(signal)
|
|
62
|
+
raise NotImplementedError, "#{self.class}#raise_signal is not implemented"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Wakes up all waiters suspended on +signal+.
|
|
66
|
+
#
|
|
67
|
+
# @param signal [Object] signal handle returned by {#new_signal}
|
|
68
|
+
# @return [void]
|
|
69
|
+
# @api private
|
|
70
|
+
def raise_signal_all(signal)
|
|
71
|
+
raise NotImplementedError, "#{self.class}#raise_signal_all is not implemented"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Spawns a new task.
|
|
75
|
+
#
|
|
76
|
+
# @param name [String, nil] optional human-readable label
|
|
77
|
+
# @param parent [Task, nil] parent task for cancellation propagation
|
|
78
|
+
# @yield block to execute concurrently (or synchronously, depending on
|
|
79
|
+
# the concrete scheduler)
|
|
80
|
+
# @return [Task]
|
|
81
|
+
# @api private
|
|
82
|
+
def spawn(name:, parent:, &block)
|
|
83
|
+
raise NotImplementedError, "#{self.class}#spawn is not implemented"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Cooperative yield point.
|
|
87
|
+
#
|
|
88
|
+
# Default implementation is a no-op. Thread-based subclasses should
|
|
89
|
+
# override with +Thread.pass+; fiber-based subclasses should switch to the
|
|
90
|
+
# next runnable fiber.
|
|
91
|
+
# @return [void]
|
|
92
|
+
# @api private
|
|
93
|
+
def yield
|
|
94
|
+
# no-op by default; subclasses override
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
class Runtime
|
|
5
|
+
# A drop-in replacement for {TimerQueue} that delegates timer scheduling to
|
|
6
|
+
# a {DeterministicScheduler} instead of spawning a dedicated background OS thread.
|
|
7
|
+
#
|
|
8
|
+
# When a {Runtime} is backed by {DeterministicScheduler} (e.g. the +:fiber+
|
|
9
|
+
# runtime backend), {Runtime#timer_queue} returns an instance of this adapter
|
|
10
|
+
# rather than a {TimerQueue}. This eliminates the +phronomy-timer-queue+
|
|
11
|
+
# background thread for Fiber-based runtimes.
|
|
12
|
+
#
|
|
13
|
+
# Timer callbacks are fired during {DeterministicScheduler#run_until_idle}
|
|
14
|
+
# when {DeterministicScheduler#autorun?} is +true+ (i.e. the +:fiber+ backend).
|
|
15
|
+
# They can also be fired explicitly by calling
|
|
16
|
+
# {DeterministicScheduler#fire_real_timers}.
|
|
17
|
+
#
|
|
18
|
+
# == Known Limitation (Issue #331)
|
|
19
|
+
#
|
|
20
|
+
# Timers that require an actual wall-clock sleep (e.g. a deadline of 10 s
|
|
21
|
+
# from now that will not be reached until real time elapses) will not fire
|
|
22
|
+
# automatically: +run_until_idle+ does not block waiting for future deadlines.
|
|
23
|
+
# This is an accepted limitation of the current stepping-stone implementation.
|
|
24
|
+
# Full resolution requires integrating the cooperative scheduler with the
|
|
25
|
+
# {EventLoop} tick cycle so that a single event-loop iteration checks both
|
|
26
|
+
# ready Fibers and expired wall-clock timers.
|
|
27
|
+
#
|
|
28
|
+
# Use the +:thread+ runtime backend (default) for production workloads that
|
|
29
|
+
# depend on real-time deadline enforcement.
|
|
30
|
+
#
|
|
31
|
+
# @see DeterministicScheduler#schedule_real_after
|
|
32
|
+
# @see DeterministicScheduler#fire_real_timers
|
|
33
|
+
# Bridges wall-clock timers to the cooperative {DeterministicScheduler}.
|
|
34
|
+
#
|
|
35
|
+
# Registers a recurring timer callback with the scheduler's {TimerQueue}
|
|
36
|
+
# so that Fiber-based tasks can await real time without blocking OS threads.
|
|
37
|
+
# @api private
|
|
38
|
+
class SchedulerTimerAdapter
|
|
39
|
+
# @param scheduler [DeterministicScheduler]
|
|
40
|
+
# @api private
|
|
41
|
+
def initialize(scheduler)
|
|
42
|
+
@scheduler = scheduler
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Schedules a one-shot callback to fire after +seconds+ from now.
|
|
46
|
+
# Delegates to {DeterministicScheduler#schedule_real_after}.
|
|
47
|
+
#
|
|
48
|
+
# Raises {PoolShutdownError} after {#shutdown} has been called, matching
|
|
49
|
+
# the behaviour of {TimerQueue#schedule}.
|
|
50
|
+
#
|
|
51
|
+
# @param seconds [Numeric] delay before the callback fires
|
|
52
|
+
# @yield called when the deadline is reached
|
|
53
|
+
# @return [self]
|
|
54
|
+
# @api private
|
|
55
|
+
def schedule(seconds:, &callback)
|
|
56
|
+
raise Phronomy::PoolShutdownError, "SchedulerTimerAdapter has been shut down" if @stopped
|
|
57
|
+
|
|
58
|
+
@scheduler.schedule_real_after(seconds, &callback)
|
|
59
|
+
self
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# No-op: there is no background thread to stop.
|
|
63
|
+
# Present for API compatibility with {TimerQueue}.
|
|
64
|
+
# @return [self]
|
|
65
|
+
# @api private
|
|
66
|
+
def shutdown
|
|
67
|
+
@stopped = true
|
|
68
|
+
self
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Returns the number of pending (not yet fired) callbacks.
|
|
72
|
+
# @return [Integer]
|
|
73
|
+
# @api private
|
|
74
|
+
def pending_count
|
|
75
|
+
@scheduler.pending_real_timer_count
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
class Runtime
|
|
5
|
+
# Internal registry of active {Task} instances for a {Runtime}.
|
|
6
|
+
#
|
|
7
|
+
# Tracks every task that has been spawned but not yet completed so that
|
|
8
|
+
# {Runtime#shutdown} can drain them. Tasks that complete synchronously
|
|
9
|
+
# (e.g. under {FakeScheduler} / {ImmediateBackend}) deregister themselves
|
|
10
|
+
# before the caller returns from {Runtime#spawn}, so they are never added
|
|
11
|
+
# to the registry in the first place.
|
|
12
|
+
# @api private
|
|
13
|
+
class TaskRegistry
|
|
14
|
+
def initialize
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
@tasks = []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Adds +task+ to the registry unless it already completed synchronously.
|
|
20
|
+
# @param task [Task]
|
|
21
|
+
# @return [void]
|
|
22
|
+
# @api private
|
|
23
|
+
def register(task)
|
|
24
|
+
@mutex.synchronize { @tasks << task unless task.done? }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Removes +task+ from the registry (called from the task's ensure block).
|
|
28
|
+
# @param task [Task]
|
|
29
|
+
# @return [void]
|
|
30
|
+
# @api private
|
|
31
|
+
def deregister(task)
|
|
32
|
+
@mutex.synchronize { @tasks.delete(task) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Waits for all registered tasks to finish (used by {Runtime#shutdown}).
|
|
36
|
+
# @return [void]
|
|
37
|
+
# @api private
|
|
38
|
+
def drain
|
|
39
|
+
tasks = @mutex.synchronize { @tasks.dup }
|
|
40
|
+
tasks.each do |t|
|
|
41
|
+
t.join
|
|
42
|
+
rescue
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
class Runtime
|
|
5
|
+
# Thread-based scheduler: spawns each task in a new OS thread.
|
|
6
|
+
#
|
|
7
|
+
# This is the default scheduler used by {Runtime} in production.
|
|
8
|
+
# It delegates directly to {Task.spawn} with the default
|
|
9
|
+
# {Task::ThreadBackend}, preserving the pre-282 behaviour exactly.
|
|
10
|
+
# @api private
|
|
11
|
+
class ThreadScheduler < Scheduler
|
|
12
|
+
# Spawns +block+ as a new {Task} backed by a Thread.
|
|
13
|
+
#
|
|
14
|
+
# @param name [String, nil]
|
|
15
|
+
# @param parent [Task, nil]
|
|
16
|
+
# @return [Task]
|
|
17
|
+
# @api private
|
|
18
|
+
def spawn(name:, parent:, &block)
|
|
19
|
+
Task.spawn(name: name, parent: parent, &block)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Yields the current thread's time slice to other runnable threads.
|
|
23
|
+
# @return [void]
|
|
24
|
+
# @api private
|
|
25
|
+
def yield
|
|
26
|
+
Thread.pass
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|