timex 0.1.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 +7 -0
- data/.DS_Store +0 -0
- data/CHANGELOG.md +11 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +4 -0
- data/README.md +112 -0
- data/Rakefile +28 -0
- data/lib/generators/timex/install_generator.rb +21 -0
- data/lib/generators/timex/templates/install.rb +54 -0
- data/lib/timex/auto_check.rb +59 -0
- data/lib/timex/cancellation_token.rb +84 -0
- data/lib/timex/clock.rb +113 -0
- data/lib/timex/composers/adaptive.rb +222 -0
- data/lib/timex/composers/base.rb +20 -0
- data/lib/timex/composers/hedged.rb +146 -0
- data/lib/timex/composers/two_phase.rb +97 -0
- data/lib/timex/configuration.rb +163 -0
- data/lib/timex/deadline.rb +458 -0
- data/lib/timex/expired.rb +77 -0
- data/lib/timex/named_component.rb +33 -0
- data/lib/timex/on_timeout.rb +15 -0
- data/lib/timex/propagation/http_header.rb +49 -0
- data/lib/timex/propagation/rack_middleware.rb +180 -0
- data/lib/timex/registry.rb +132 -0
- data/lib/timex/result.rb +137 -0
- data/lib/timex/strategies/base.rb +88 -0
- data/lib/timex/strategies/closeable.rb +81 -0
- data/lib/timex/strategies/cooperative.rb +27 -0
- data/lib/timex/strategies/io.rb +247 -0
- data/lib/timex/strategies/ractor.rb +84 -0
- data/lib/timex/strategies/subprocess.rb +267 -0
- data/lib/timex/strategies/unsafe.rb +54 -0
- data/lib/timex/strategies/wakeup.rb +154 -0
- data/lib/timex/telemetry/adapters.rb +173 -0
- data/lib/timex/telemetry.rb +119 -0
- data/lib/timex/test/virtual_clock.rb +51 -0
- data/lib/timex/timeout_handling.rb +39 -0
- data/lib/timex/version.rb +8 -0
- data/lib/timex.rb +79 -0
- data/mkdocs.yml +193 -0
- metadata +239 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TIMEx
|
|
4
|
+
module Composers
|
|
5
|
+
# Chooses a per-call child deadline from a latency estimator, then delegates
|
|
6
|
+
# to +child+ with +on_timeout: :raise+ so timeouts feed back into the
|
|
7
|
+
# estimator uniformly before applying the caller's +on_timeout:+.
|
|
8
|
+
#
|
|
9
|
+
# @see Base
|
|
10
|
+
class Adaptive < Base
|
|
11
|
+
|
|
12
|
+
# O(1) streaming quantile estimator (P² algorithm, Jain & Chlamtac 1985).
|
|
13
|
+
#
|
|
14
|
+
# {#record} updates markers; {#estimate_ms} reads the last published estimate
|
|
15
|
+
# without locking. Markers reset every +window+ samples.
|
|
16
|
+
#
|
|
17
|
+
# @note {#estimate_ms} is intentionally lock-free; it may briefly return a
|
|
18
|
+
# stale value while {#record} runs on another thread.
|
|
19
|
+
class InMemoryStore
|
|
20
|
+
|
|
21
|
+
P_DEFAULT = 0.99
|
|
22
|
+
|
|
23
|
+
# @param window [Integer] samples before marker reset
|
|
24
|
+
# @param alpha [Float] EWMA smoothing factor for the safety margin
|
|
25
|
+
# @param p [Float] target quantile (default ~p99)
|
|
26
|
+
def initialize(window: 200, alpha: 0.2, p: P_DEFAULT)
|
|
27
|
+
@window = window
|
|
28
|
+
@alpha = alpha
|
|
29
|
+
@p = p
|
|
30
|
+
@ewma = nil
|
|
31
|
+
@count = 0
|
|
32
|
+
@mutex = Mutex.new
|
|
33
|
+
@last_estimate_ms = nil
|
|
34
|
+
reset_markers
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Records a latency sample in milliseconds and refreshes the published estimate.
|
|
38
|
+
#
|
|
39
|
+
# @param ms [Numeric] observed latency in ms
|
|
40
|
+
# @return [self]
|
|
41
|
+
def record(ms)
|
|
42
|
+
@mutex.synchronize do
|
|
43
|
+
reset_markers if @count >= @window
|
|
44
|
+
@count += 1
|
|
45
|
+
@ewma = @ewma.nil? ? ms : (@alpha * ms) + ((1 - @alpha) * @ewma)
|
|
46
|
+
|
|
47
|
+
if @q.nil?
|
|
48
|
+
@initial << ms
|
|
49
|
+
promote_to_psquare if @initial.size == 5
|
|
50
|
+
else
|
|
51
|
+
psquare_step(ms)
|
|
52
|
+
end
|
|
53
|
+
# Publish the post-record estimate for lock-free reads. Single ivar
|
|
54
|
+
# write is atomic in MRI; readers see either the previous or new
|
|
55
|
+
# value, both of which are valid.
|
|
56
|
+
@last_estimate_ms = compute_estimate
|
|
57
|
+
end
|
|
58
|
+
self
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Lock-free read: {#record} publishes a fresh estimate at the end of
|
|
62
|
+
# every call under the mutex. Readers don't need to synchronize.
|
|
63
|
+
#
|
|
64
|
+
# @return [Float, nil] last estimated budget in ms, or +nil+ when empty
|
|
65
|
+
def estimate_ms
|
|
66
|
+
@last_estimate_ms
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def compute_estimate
|
|
72
|
+
return nil if @count.zero?
|
|
73
|
+
|
|
74
|
+
base = if @q
|
|
75
|
+
@q[2]
|
|
76
|
+
else
|
|
77
|
+
sorted = @initial.sort
|
|
78
|
+
sorted[((sorted.size - 1) * @p).round]
|
|
79
|
+
end
|
|
80
|
+
[base, (@ewma || 0) * 3].max
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def reset_markers
|
|
84
|
+
@count = 0
|
|
85
|
+
@initial = []
|
|
86
|
+
@q = nil
|
|
87
|
+
@n = nil
|
|
88
|
+
@np = nil
|
|
89
|
+
@dn = nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def promote_to_psquare
|
|
93
|
+
@q = @initial.sort
|
|
94
|
+
@n = [1, 2, 3, 4, 5]
|
|
95
|
+
@np = [1.0, 1.0 + (2.0 * @p), 1.0 + (4.0 * @p), 3.0 + (2.0 * @p), 5.0]
|
|
96
|
+
@dn = [0.0, @p / 2.0, @p, (1.0 + @p) / 2.0, 1.0]
|
|
97
|
+
@initial = nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def psquare_step(x)
|
|
101
|
+
k = locate_cell(x)
|
|
102
|
+
((k + 1)..4).each { |i| @n[i] += 1 }
|
|
103
|
+
5.times { |i| @np[i] += @dn[i] }
|
|
104
|
+
(1..3).each { |i| adjust_marker(i) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def locate_cell(x)
|
|
108
|
+
if x < @q[0]
|
|
109
|
+
@q[0] = x
|
|
110
|
+
0
|
|
111
|
+
elsif x < @q[1] then 0
|
|
112
|
+
elsif x < @q[2] then 1
|
|
113
|
+
elsif x < @q[3] then 2
|
|
114
|
+
elsif x <= @q[4] then 3
|
|
115
|
+
else
|
|
116
|
+
@q[4] = x
|
|
117
|
+
3
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def adjust_marker(i)
|
|
122
|
+
d = @np[i] - @n[i]
|
|
123
|
+
return unless (d >= 1 && @n[i + 1] - @n[i] > 1) || (d <= -1 && @n[i - 1] - @n[i] < -1)
|
|
124
|
+
|
|
125
|
+
d = d.positive? ? 1 : -1
|
|
126
|
+
qp = parabolic(i, d)
|
|
127
|
+
qp = linear(i, d) if qp <= @q[i - 1] || qp >= @q[i + 1]
|
|
128
|
+
@q[i] = qp
|
|
129
|
+
@n[i] += d
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def parabolic(i, d)
|
|
133
|
+
@q[i] + ((d.to_f / (@n[i + 1] - @n[i - 1])) *
|
|
134
|
+
((((@n[i] - @n[i - 1] + d) * (@q[i + 1] - @q[i])) / (@n[i + 1] - @n[i])) +
|
|
135
|
+
(((@n[i + 1] - @n[i] - d) * (@q[i] - @q[i - 1])) / (@n[i] - @n[i - 1]))))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def linear(i, d)
|
|
139
|
+
@q[i] + ((d * (@q[i + d] - @q[i])) / (@n[i + d] - @n[i]))
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# @param child [Symbol, Strategies::Base] inner strategy
|
|
145
|
+
# @param history [#estimate_ms, #record] latency store (defaults to {InMemoryStore})
|
|
146
|
+
# @param multiplier [Numeric] scales the estimate into a budget
|
|
147
|
+
# @param floor_ms [Numeric] minimum adaptive budget
|
|
148
|
+
# @param ceiling_ms [Numeric] maximum adaptive budget
|
|
149
|
+
# @raise [ArgumentError] when parameters are invalid
|
|
150
|
+
def initialize(child:, history: InMemoryStore.new, multiplier: 1.5, floor_ms: 25, ceiling_ms: 30_000)
|
|
151
|
+
super()
|
|
152
|
+
raise ArgumentError, "multiplier must be > 0" unless multiplier.is_a?(Numeric) && multiplier.positive?
|
|
153
|
+
raise ArgumentError, "floor_ms must be a positive Numeric" unless floor_ms.is_a?(Numeric) && floor_ms.positive?
|
|
154
|
+
raise ArgumentError, "ceiling_ms must be >= floor_ms" unless ceiling_ms.is_a?(Numeric) && ceiling_ms >= floor_ms
|
|
155
|
+
|
|
156
|
+
@child = Registry.resolve(child)
|
|
157
|
+
@history = history
|
|
158
|
+
@multiplier = multiplier
|
|
159
|
+
@floor_ms = floor_ms
|
|
160
|
+
@ceiling_ms = ceiling_ms
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# @param deadline [Deadline, Numeric, Time, nil, Object] optional outer cap (+min+ with adaptive budget)
|
|
164
|
+
# @param on_timeout [Symbol, Proc] applied after child raises {Expired}
|
|
165
|
+
# @param opts [Hash{Symbol => Object}] forwarded to +child+
|
|
166
|
+
# @yieldparam deadline [Deadline]
|
|
167
|
+
# @return [Object] child return or timeout handler result
|
|
168
|
+
# @raise [StandardError] non-timeout errors from the child propagate after recording latency
|
|
169
|
+
def call(deadline: nil, on_timeout: :raise, **opts, &block)
|
|
170
|
+
estimate = @history.estimate_ms
|
|
171
|
+
budget_ms = if estimate
|
|
172
|
+
(estimate * @multiplier).clamp(@floor_ms, @ceiling_ms)
|
|
173
|
+
else
|
|
174
|
+
@ceiling_ms
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
adaptive_deadline = Deadline.in(budget_ms / 1000.0)
|
|
178
|
+
effective = deadline ? Deadline.coerce(deadline).min(adaptive_deadline) : adaptive_deadline
|
|
179
|
+
|
|
180
|
+
TIMEx::Telemetry.instrument(
|
|
181
|
+
event: "composer.adaptive",
|
|
182
|
+
estimate_ms: estimate&.round,
|
|
183
|
+
budget_ms: budget_ms.round,
|
|
184
|
+
deadline_ms: effective.infinite? ? nil : effective.remaining_ms.round
|
|
185
|
+
) do |payload|
|
|
186
|
+
started = Clock.monotonic_ns
|
|
187
|
+
begin
|
|
188
|
+
# Force the child to surface `Expired` so we can record a uniform
|
|
189
|
+
# timeout penalty regardless of the caller's `on_timeout:` (a
|
|
190
|
+
# `:return_nil`/`:result` path would otherwise be recorded as a
|
|
191
|
+
# success at ~budget_ms and never penalize the estimator). We
|
|
192
|
+
# re-apply the caller's `on_timeout:` ourselves.
|
|
193
|
+
value = @child.call(deadline: effective, on_timeout: :raise, **opts, &block)
|
|
194
|
+
@history.record((Clock.monotonic_ns - started) / 1_000_000.0)
|
|
195
|
+
value
|
|
196
|
+
rescue Expired => e
|
|
197
|
+
payload[:outcome] = :timeout
|
|
198
|
+
# Record the *budget* as the penalty (capped at ceiling), not the
|
|
199
|
+
# multiplied estimate. Previously we recorded the parent-clamped
|
|
200
|
+
# budget_ms back into history, which on a tight parent deadline
|
|
201
|
+
# could differ from what we actually waited and bias the estimator.
|
|
202
|
+
# Use `effective.remaining_ms` (post-clamp elapsed) if available so
|
|
203
|
+
# the estimator tracks real wait time, falling back to budget_ms.
|
|
204
|
+
elapsed_ms = (Clock.monotonic_ns - started) / 1_000_000.0
|
|
205
|
+
@history.record([elapsed_ms, budget_ms.to_f].max.clamp(@floor_ms, @ceiling_ms))
|
|
206
|
+
handle_timeout(on_timeout, e)
|
|
207
|
+
rescue StandardError
|
|
208
|
+
# User-cancelled or otherwise-failed attempts should still feed
|
|
209
|
+
# the estimator: a child that consistently raises after ~budget_ms
|
|
210
|
+
# tells us latency is rising, even if the caller is the one
|
|
211
|
+
# throwing the exception. Cap the recorded sample at the ceiling
|
|
212
|
+
# so a slow upstream-of-failure doesn't pin the estimator high.
|
|
213
|
+
elapsed_ms = (Clock.monotonic_ns - started) / 1_000_000.0
|
|
214
|
+
@history.record(elapsed_ms.clamp(@floor_ms, @ceiling_ms))
|
|
215
|
+
raise
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TIMEx
|
|
4
|
+
module Composers
|
|
5
|
+
# Shared mixin for composer objects that orchestrate one or more strategies.
|
|
6
|
+
#
|
|
7
|
+
# Composers behave like strategies: they accept +deadline:+ / +on_timeout:+,
|
|
8
|
+
# expose {.name_symbol} via {NamedComponent}, and route {Expired} through
|
|
9
|
+
# {TimeoutHandling}. Subclasses implement +#call+ with their own scheduling.
|
|
10
|
+
#
|
|
11
|
+
# @see TimeoutHandling
|
|
12
|
+
# @see NamedComponent
|
|
13
|
+
class Base
|
|
14
|
+
|
|
15
|
+
include TIMEx::NamedComponent
|
|
16
|
+
include TIMEx::TimeoutHandling
|
|
17
|
+
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TIMEx
|
|
4
|
+
module Composers
|
|
5
|
+
# Launches up to +max:+ staggered parallel attempts (+after:+ seconds apart)
|
|
6
|
+
# of +child+ and returns the first successful result.
|
|
7
|
+
#
|
|
8
|
+
# @note Losers are stopped with +Thread#kill+; the block must tolerate
|
|
9
|
+
# concurrent execution, partial side effects, and abrupt termination.
|
|
10
|
+
#
|
|
11
|
+
# @see Base
|
|
12
|
+
class Hedged < Base
|
|
13
|
+
|
|
14
|
+
# @param after [Numeric] seconds between successive attempt launches
|
|
15
|
+
# @param child [Symbol, Strategies::Base] strategy used per attempt
|
|
16
|
+
# @param max [Integer] maximum concurrent attempts (>= 1)
|
|
17
|
+
# @param idempotent [Boolean] must be +true+; acknowledges concurrent/kill semantics
|
|
18
|
+
# @raise [ArgumentError] when parameters are invalid
|
|
19
|
+
def initialize(after:, child:, max: 2, idempotent: false)
|
|
20
|
+
super()
|
|
21
|
+
raise ArgumentError, "Hedged requires idempotent: true" unless idempotent
|
|
22
|
+
raise ArgumentError, "after must be a non-negative Numeric" unless after.is_a?(Numeric) && !after.negative?
|
|
23
|
+
raise ArgumentError, "max must be >= 1" if max < 1
|
|
24
|
+
|
|
25
|
+
@after = after
|
|
26
|
+
@max = max
|
|
27
|
+
@child = Registry.resolve(child)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @param deadline [Deadline, Numeric, Time, nil]
|
|
31
|
+
# @param on_timeout [Symbol, Proc]
|
|
32
|
+
# @param opts [Hash{Symbol => Object}] forwarded to each child attempt
|
|
33
|
+
# @yieldparam deadline [Deadline]
|
|
34
|
+
# @return [Object] first successful value or handler result
|
|
35
|
+
# @raise [StandardError] re-raised from a failed attempt when no success precedes it
|
|
36
|
+
def call(deadline:, on_timeout: :raise, **opts, &block)
|
|
37
|
+
deadline = Deadline.coerce(deadline)
|
|
38
|
+
results = Queue.new
|
|
39
|
+
# Side-channel of "a result just landed" notifications so the spawn
|
|
40
|
+
# loop can block on `signal.pop(timeout:)` instead of polling — we
|
|
41
|
+
# can't peek at `results` non-destructively without disturbing the
|
|
42
|
+
# consumption order of `await_outcome`.
|
|
43
|
+
signal = Queue.new
|
|
44
|
+
threads = []
|
|
45
|
+
|
|
46
|
+
threads << launch(deadline, results, signal, opts, &block)
|
|
47
|
+
until !results.empty? || (threads.size >= @max) || deadline.expired?
|
|
48
|
+
status = wait_for_result(@after, signal, deadline)
|
|
49
|
+
break if status == :result_ready
|
|
50
|
+
# `wait_for_result` returns `:expired` when the parent deadline
|
|
51
|
+
# elapsed during the wait. Bail before launching a redundant
|
|
52
|
+
# worker that would race against the expiration.
|
|
53
|
+
break if status == :expired
|
|
54
|
+
|
|
55
|
+
threads << launch(deadline, results, signal, opts, &block)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
outcome = await_outcome(results, threads.size, deadline)
|
|
59
|
+
threads.each { |t| t.kill if t.alive? }
|
|
60
|
+
|
|
61
|
+
case outcome[0]
|
|
62
|
+
when :ok then outcome[1]
|
|
63
|
+
when :error then raise outcome[1]
|
|
64
|
+
when :timeout
|
|
65
|
+
handle_timeout(
|
|
66
|
+
on_timeout,
|
|
67
|
+
deadline.expired_error(
|
|
68
|
+
strategy: :hedged,
|
|
69
|
+
message: "all hedged attempts timed out"
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# Drains queued results so that a non-`:ok` outcome doesn't beat a still-
|
|
78
|
+
# pending successful attempt. Returns the first `:ok`, otherwise the last
|
|
79
|
+
# outcome seen (preferring `:error` over `:timeout`). Bounded by the
|
|
80
|
+
# parent deadline so a non-cooperative child cannot hang the composer.
|
|
81
|
+
#
|
|
82
|
+
# @param results [Queue]
|
|
83
|
+
# @param expected [Integer] number of workers launched
|
|
84
|
+
# @param deadline [Deadline]
|
|
85
|
+
# @return [Array] +[:ok, value]+, +[:error, exception]+, or +[:timeout]+
|
|
86
|
+
def await_outcome(results, expected, deadline)
|
|
87
|
+
seen = []
|
|
88
|
+
expected.times do
|
|
89
|
+
remaining = deadline.infinite? ? nil : [deadline.remaining, 0.0].max
|
|
90
|
+
outcome = remaining.nil? ? results.pop : results.pop(timeout: remaining)
|
|
91
|
+
if outcome.nil?
|
|
92
|
+
# Drain anything that landed between the previous pop and now so a
|
|
93
|
+
# late-arriving winner isn't dropped on a tight deadline.
|
|
94
|
+
until results.empty?
|
|
95
|
+
late = results.pop(timeout: 0)
|
|
96
|
+
break if late.nil?
|
|
97
|
+
return late if late[0] == :ok
|
|
98
|
+
|
|
99
|
+
seen << late
|
|
100
|
+
end
|
|
101
|
+
break
|
|
102
|
+
end
|
|
103
|
+
return outcome if outcome[0] == :ok
|
|
104
|
+
|
|
105
|
+
seen << outcome
|
|
106
|
+
end
|
|
107
|
+
seen.find { |o| o[0] == :error } || seen.last || [:timeout]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# @return [Thread]
|
|
111
|
+
def launch(deadline, results, signal, opts, &block)
|
|
112
|
+
Thread.new do
|
|
113
|
+
value = @child.call(deadline:, on_timeout: :raise, **opts, &block)
|
|
114
|
+
results << [:ok, value]
|
|
115
|
+
signal << :ready
|
|
116
|
+
rescue Expired
|
|
117
|
+
results << [:timeout]
|
|
118
|
+
signal << :ready
|
|
119
|
+
rescue StandardError => e
|
|
120
|
+
results << [:error, e]
|
|
121
|
+
signal << :ready
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Blocks for up to +seconds+ (or until the parent deadline elapses)
|
|
126
|
+
# waiting for a worker to enqueue a result. Uses a notification queue so
|
|
127
|
+
# we don't have to poll or peek at +results+. Returns +:expired+ when
|
|
128
|
+
# the parent deadline already elapsed (so +signal.pop(timeout: 0)+
|
|
129
|
+
# would no-op and the caller would otherwise spawn a redundant worker
|
|
130
|
+
# before the outer +deadline.expired?+ re-check).
|
|
131
|
+
#
|
|
132
|
+
# @param seconds [Numeric]
|
|
133
|
+
# @param signal [Queue]
|
|
134
|
+
# @param deadline [Deadline]
|
|
135
|
+
# @return [Symbol] +:result_ready+, +:expired+, or +:time_up+
|
|
136
|
+
def wait_for_result(seconds, signal, deadline)
|
|
137
|
+
return :result_ready if signal.pop(timeout: 0)
|
|
138
|
+
return :expired if !deadline.infinite? && deadline.remaining <= 0
|
|
139
|
+
|
|
140
|
+
wait = deadline.infinite? ? seconds : [deadline.remaining, seconds].min
|
|
141
|
+
signal.pop(timeout: wait) ? :result_ready : :time_up
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TIMEx
|
|
4
|
+
module Composers
|
|
5
|
+
# Runs a **soft** strategy first, then escalates to a **hard** strategy after
|
|
6
|
+
# +grace+ seconds beyond the soft budget, killing the soft worker and
|
|
7
|
+
# re-invoking the block (requires +idempotent: true+).
|
|
8
|
+
#
|
|
9
|
+
# @see Base
|
|
10
|
+
class TwoPhase < Base
|
|
11
|
+
|
|
12
|
+
attr_reader :soft, :hard, :grace, :hard_deadline, :idempotent
|
|
13
|
+
|
|
14
|
+
# @param soft [Symbol, Strategies::Base] strategy for the first attempt
|
|
15
|
+
# @param hard [Symbol, Strategies::Base] strategy that forcibly bounds the second attempt
|
|
16
|
+
# @param grace [Numeric] seconds after soft budget before escalation
|
|
17
|
+
# @param hard_deadline [Numeric] hard-phase budget in seconds (clamped to parent remaining)
|
|
18
|
+
# @param idempotent [Boolean] must be +true+; acknowledges the block may run twice
|
|
19
|
+
# @raise [ArgumentError] when invariants are violated
|
|
20
|
+
def initialize(soft:, hard:, grace: 0.5, hard_deadline: 1.0, idempotent: false)
|
|
21
|
+
super()
|
|
22
|
+
raise ArgumentError, "TwoPhase escalates by re-invoking the block; pass idempotent: true to acknowledge" unless idempotent
|
|
23
|
+
raise ArgumentError, "grace must be a non-negative Numeric" unless grace.is_a?(Numeric) && !grace.negative?
|
|
24
|
+
raise ArgumentError, "hard_deadline must be a positive Numeric" unless hard_deadline.is_a?(Numeric) && hard_deadline.positive?
|
|
25
|
+
|
|
26
|
+
@soft = Registry.resolve(soft)
|
|
27
|
+
@hard = Registry.resolve(hard)
|
|
28
|
+
@grace = grace
|
|
29
|
+
@hard_deadline = hard_deadline
|
|
30
|
+
@idempotent = idempotent
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param deadline [Deadline, Numeric, Time, nil]
|
|
34
|
+
# @param on_timeout [Symbol, Proc]
|
|
35
|
+
# @param opts [Hash{Symbol => Object}] forwarded to child strategies
|
|
36
|
+
# @yieldparam deadline [Deadline]
|
|
37
|
+
# @return [Object] soft-path value, hard-path value, or handler result
|
|
38
|
+
# @raise [StandardError] when the soft worker raises a non-timeout error
|
|
39
|
+
def call(deadline:, on_timeout: :raise, **opts, &block)
|
|
40
|
+
deadline = Deadline.coerce(deadline)
|
|
41
|
+
soft_budget = deadline.infinite? ? nil : deadline.remaining
|
|
42
|
+
wait = soft_budget ? soft_budget + @grace : nil
|
|
43
|
+
|
|
44
|
+
TIMEx::Telemetry.instrument(
|
|
45
|
+
event: "composer.two_phase",
|
|
46
|
+
soft_ms: soft_budget && (soft_budget * 1000).round,
|
|
47
|
+
grace_ms: (@grace * 1000).round
|
|
48
|
+
) do |payload|
|
|
49
|
+
queue = Queue.new
|
|
50
|
+
worker = Thread.new do
|
|
51
|
+
value = @soft.call(deadline:, on_timeout: :raise, **opts, &block)
|
|
52
|
+
queue << [:ok, value]
|
|
53
|
+
rescue Expired => e
|
|
54
|
+
queue << [:soft_timeout, e]
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
queue << [:error, e]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
if (outcome = pop_with_timeout(queue, wait))
|
|
60
|
+
kind, value = outcome
|
|
61
|
+
payload[:outcome] = kind == :ok ? :ok : kind
|
|
62
|
+
return value if kind == :ok
|
|
63
|
+
raise value if kind == :error
|
|
64
|
+
|
|
65
|
+
return handle_timeout(on_timeout, value)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Worker exceeded soft + grace. Force-stop and escalate.
|
|
69
|
+
worker.kill
|
|
70
|
+
payload[:soft_timeout] = true
|
|
71
|
+
|
|
72
|
+
# Clamp the hard-phase budget to whatever remains on the parent
|
|
73
|
+
# deadline, so escalation cannot extend the caller's contract.
|
|
74
|
+
hard_deadline = Deadline.in(@hard_deadline).min(deadline)
|
|
75
|
+
begin
|
|
76
|
+
@hard.call(deadline: hard_deadline, on_timeout: :raise, **opts, &block)
|
|
77
|
+
rescue Expired => e
|
|
78
|
+
payload[:outcome] = :hard_timeout
|
|
79
|
+
handle_timeout(on_timeout, e)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# @param queue [Queue]
|
|
87
|
+
# @param seconds [Numeric, nil]
|
|
88
|
+
# @return [Array, nil] queued pair or +nil+ on timeout
|
|
89
|
+
def pop_with_timeout(queue, seconds)
|
|
90
|
+
return queue.pop if seconds.nil?
|
|
91
|
+
|
|
92
|
+
queue.pop(timeout: seconds)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
require_relative "on_timeout"
|
|
5
|
+
|
|
6
|
+
module TIMEx
|
|
7
|
+
|
|
8
|
+
# Mutable process-wide defaults for {TIMEx.deadline}, propagation, telemetry, and
|
|
9
|
+
# clock selection.
|
|
10
|
+
#
|
|
11
|
+
# Read through {TIMEx.configuration}; updates should go through {TIMEx.configure}
|
|
12
|
+
# so in-flight callers never observe a half-mutated instance.
|
|
13
|
+
#
|
|
14
|
+
# @see TIMEx.configure
|
|
15
|
+
# @see TIMEx.configuration
|
|
16
|
+
class Configuration
|
|
17
|
+
|
|
18
|
+
attr_reader :default_strategy, :default_on_timeout, :auto_check_default,
|
|
19
|
+
:auto_check_interval, :telemetry_adapter, :clock, :skew_tolerance_ms
|
|
20
|
+
|
|
21
|
+
# Builds defaults aligned with conservative production behavior.
|
|
22
|
+
#
|
|
23
|
+
# @return [void]
|
|
24
|
+
def initialize
|
|
25
|
+
@default_strategy = :cooperative
|
|
26
|
+
@default_on_timeout = :raise
|
|
27
|
+
@auto_check_default = false
|
|
28
|
+
@auto_check_interval = 1000
|
|
29
|
+
@telemetry_adapter = nil
|
|
30
|
+
@clock = nil
|
|
31
|
+
@skew_tolerance_ms = Deadline::DEFAULT_SKEW_TOLERANCE_MS
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param value [Symbol, #call] registered strategy key or callable strategy
|
|
35
|
+
# @return [Symbol, #call] the assigned strategy
|
|
36
|
+
# @raise [ConfigurationError] when +value+ is neither a Symbol nor callable
|
|
37
|
+
def default_strategy=(value)
|
|
38
|
+
raise ConfigurationError, "default_strategy must be a Symbol or strategy class" unless value.is_a?(Symbol) || value.respond_to?(:call)
|
|
39
|
+
|
|
40
|
+
@default_strategy = value
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param value [Symbol, Proc] one of {ON_TIMEOUT_SYMBOLS} or a custom Proc
|
|
44
|
+
# @return [Symbol, Proc] the assigned handler
|
|
45
|
+
# @raise [ConfigurationError] when +value+ is not allowed
|
|
46
|
+
def default_on_timeout=(value)
|
|
47
|
+
unless ON_TIMEOUT_SYMBOLS.include?(value) || value.is_a?(Proc)
|
|
48
|
+
raise ConfigurationError,
|
|
49
|
+
"default_on_timeout must be one of #{ON_TIMEOUT_SYMBOLS.inspect} or a Proc"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
@default_on_timeout = value
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @param value [Boolean]
|
|
56
|
+
# @return [Boolean]
|
|
57
|
+
# @raise [ConfigurationError] when not strictly +true+ or +false+
|
|
58
|
+
def auto_check_default=(value)
|
|
59
|
+
raise ConfigurationError, "auto_check_default must be true or false" unless [true, false].include?(value)
|
|
60
|
+
|
|
61
|
+
@auto_check_default = value
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @param value [Integer] milliseconds between automatic deadline checks
|
|
65
|
+
# @return [Integer]
|
|
66
|
+
# @raise [ConfigurationError] when not a positive Integer
|
|
67
|
+
def auto_check_interval=(value)
|
|
68
|
+
raise ConfigurationError, "auto_check_interval must be a positive Integer" unless value.is_a?(Integer) && value.positive?
|
|
69
|
+
|
|
70
|
+
@auto_check_interval = value
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @param value [#emit, nil] adapter object or +nil+ to fall back to global default
|
|
74
|
+
# @return [#emit, nil]
|
|
75
|
+
# @raise [ConfigurationError] when non-+nil+ and missing +#emit+
|
|
76
|
+
def telemetry_adapter=(value)
|
|
77
|
+
raise ConfigurationError, "telemetry_adapter must respond to :emit" if value && !value.respond_to?(:emit)
|
|
78
|
+
|
|
79
|
+
@telemetry_adapter = value
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @param value [nil, #monotonic_ns, #wall_ns] process-wide clock override
|
|
83
|
+
# @return [nil, Object]
|
|
84
|
+
# @raise [ConfigurationError] when non-+nil+ and missing required methods
|
|
85
|
+
def clock=(value)
|
|
86
|
+
ok = value.nil? || (value.respond_to?(:monotonic_ns) && value.respond_to?(:wall_ns))
|
|
87
|
+
raise ConfigurationError, "clock must respond to :monotonic_ns and :wall_ns" unless ok
|
|
88
|
+
|
|
89
|
+
@clock = value
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @param value [Numeric] wall skew tolerance used when parsing propagated deadlines
|
|
93
|
+
# @return [Numeric]
|
|
94
|
+
# @raise [ConfigurationError] when negative or non-numeric
|
|
95
|
+
def skew_tolerance_ms=(value)
|
|
96
|
+
raise ConfigurationError, "skew_tolerance_ms must be a non-negative Numeric" unless value.is_a?(Numeric) && !value.negative?
|
|
97
|
+
|
|
98
|
+
@skew_tolerance_ms = value
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Duplicates mutable Array/Hash fields after +dup+ so nested configuration
|
|
102
|
+
# cannot leak mutations across snapshots.
|
|
103
|
+
#
|
|
104
|
+
# @note Current fields are primitives; this is defensive for future container fields.
|
|
105
|
+
#
|
|
106
|
+
# @return [void]
|
|
107
|
+
def initialize_copy(source)
|
|
108
|
+
super
|
|
109
|
+
instance_variables.each do |iv|
|
|
110
|
+
val = instance_variable_get(iv)
|
|
111
|
+
instance_variable_set(iv, val.dup) if val.is_a?(Array) || val.is_a?(Hash)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
class << self
|
|
118
|
+
|
|
119
|
+
CONFIG_MUTEX = Monitor.new
|
|
120
|
+
private_constant :CONFIG_MUTEX
|
|
121
|
+
|
|
122
|
+
# Returns the process-wide {Configuration}, constructing it once under +CONFIG_MUTEX+.
|
|
123
|
+
#
|
|
124
|
+
# @return [Configuration]
|
|
125
|
+
def configuration
|
|
126
|
+
@configuration || CONFIG_MUTEX.synchronize { @configuration ||= Configuration.new }
|
|
127
|
+
end
|
|
128
|
+
alias config configuration
|
|
129
|
+
|
|
130
|
+
# Yields a duplicated {Configuration}, then atomically publishes it when the
|
|
131
|
+
# outermost block completes without raising.
|
|
132
|
+
#
|
|
133
|
+
# Nested +configure+ calls mutate the same draft and only the outermost swap
|
|
134
|
+
# commits, keeping re-entrant initialization safe.
|
|
135
|
+
#
|
|
136
|
+
# @yieldparam draft [Configuration] mutable copy to adjust
|
|
137
|
+
# @return [Object] the block's return value
|
|
138
|
+
# @raise [ArgumentError] when no block is given
|
|
139
|
+
def configure
|
|
140
|
+
raise ArgumentError, "TIMEx.configure requires a block" unless block_given?
|
|
141
|
+
|
|
142
|
+
CONFIG_MUTEX.synchronize do
|
|
143
|
+
outer = @configure_draft.nil?
|
|
144
|
+
@configure_draft ||= configuration.dup
|
|
145
|
+
begin
|
|
146
|
+
yield @configure_draft
|
|
147
|
+
@configuration = @configure_draft if outer
|
|
148
|
+
ensure
|
|
149
|
+
@configure_draft = nil if outer
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Replaces the configuration with a fresh {Configuration} under the mutex.
|
|
155
|
+
#
|
|
156
|
+
# @return [void]
|
|
157
|
+
def reset_configuration!
|
|
158
|
+
CONFIG_MUTEX.synchronize { @configuration = Configuration.new }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
end
|