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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/.DS_Store +0 -0
  3. data/CHANGELOG.md +11 -0
  4. data/CODE_OF_CONDUCT.md +132 -0
  5. data/LICENSE.txt +4 -0
  6. data/README.md +112 -0
  7. data/Rakefile +28 -0
  8. data/lib/generators/timex/install_generator.rb +21 -0
  9. data/lib/generators/timex/templates/install.rb +54 -0
  10. data/lib/timex/auto_check.rb +59 -0
  11. data/lib/timex/cancellation_token.rb +84 -0
  12. data/lib/timex/clock.rb +113 -0
  13. data/lib/timex/composers/adaptive.rb +222 -0
  14. data/lib/timex/composers/base.rb +20 -0
  15. data/lib/timex/composers/hedged.rb +146 -0
  16. data/lib/timex/composers/two_phase.rb +97 -0
  17. data/lib/timex/configuration.rb +163 -0
  18. data/lib/timex/deadline.rb +458 -0
  19. data/lib/timex/expired.rb +77 -0
  20. data/lib/timex/named_component.rb +33 -0
  21. data/lib/timex/on_timeout.rb +15 -0
  22. data/lib/timex/propagation/http_header.rb +49 -0
  23. data/lib/timex/propagation/rack_middleware.rb +180 -0
  24. data/lib/timex/registry.rb +132 -0
  25. data/lib/timex/result.rb +137 -0
  26. data/lib/timex/strategies/base.rb +88 -0
  27. data/lib/timex/strategies/closeable.rb +81 -0
  28. data/lib/timex/strategies/cooperative.rb +27 -0
  29. data/lib/timex/strategies/io.rb +247 -0
  30. data/lib/timex/strategies/ractor.rb +84 -0
  31. data/lib/timex/strategies/subprocess.rb +267 -0
  32. data/lib/timex/strategies/unsafe.rb +54 -0
  33. data/lib/timex/strategies/wakeup.rb +154 -0
  34. data/lib/timex/telemetry/adapters.rb +173 -0
  35. data/lib/timex/telemetry.rb +119 -0
  36. data/lib/timex/test/virtual_clock.rb +51 -0
  37. data/lib/timex/timeout_handling.rb +39 -0
  38. data/lib/timex/version.rb +8 -0
  39. data/lib/timex.rb +79 -0
  40. data/mkdocs.yml +193 -0
  41. 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