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,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TIMEx
|
|
4
|
+
module Strategies
|
|
5
|
+
# Pipe-based wakeup primitive: blocked I/O on {#read_io} unblocks when the
|
|
6
|
+
# deadline fires (or {#cancel!} is called), and the {CancellationToken}
|
|
7
|
+
# transitions to {#fired?}. Use to wake threads blocked in +IO.select+ on
|
|
8
|
+
# resources without native deadlines.
|
|
9
|
+
#
|
|
10
|
+
# Each instance is **single-use**: the pipe is created lazily and closed in
|
|
11
|
+
# +ensure+ after {#run}. Construct a fresh instance per operation.
|
|
12
|
+
#
|
|
13
|
+
# @note Accessing {#read_io} / {#write_io} before {#arm} without a constructor
|
|
14
|
+
# deadline creates the pipe but does **not** install a timer; the read end
|
|
15
|
+
# blocks until {#cancel!} unless you pass a deadline to {#initialize} or call {#arm}.
|
|
16
|
+
#
|
|
17
|
+
# @see CancellationToken
|
|
18
|
+
# @see Base
|
|
19
|
+
class Wakeup < Base
|
|
20
|
+
|
|
21
|
+
attr_reader :token
|
|
22
|
+
|
|
23
|
+
# @param deadline [Deadline, Numeric, Time, nil] when given, calls {#arm} immediately
|
|
24
|
+
def initialize(deadline = nil)
|
|
25
|
+
super()
|
|
26
|
+
@token = CancellationToken.new
|
|
27
|
+
@read_io = nil
|
|
28
|
+
@write_io = nil
|
|
29
|
+
@timer = nil
|
|
30
|
+
@closed = false
|
|
31
|
+
@io_mutex = Mutex.new
|
|
32
|
+
arm(deadline) if deadline
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [::IO] readable end of the wakeup pipe (creates the pipe lazily)
|
|
36
|
+
def read_io
|
|
37
|
+
ensure_pipe
|
|
38
|
+
@read_io
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [::IO] writable end used internally to signal readiness
|
|
42
|
+
def write_io
|
|
43
|
+
ensure_pipe
|
|
44
|
+
@write_io
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Boolean] +true+ after {#close}
|
|
48
|
+
def closed?
|
|
49
|
+
@io_mutex.synchronize { @closed }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Arms a background timer that invokes {#cancel!} with +:timeout+ when the
|
|
53
|
+
# deadline elapses.
|
|
54
|
+
#
|
|
55
|
+
# @param deadline [Deadline, Numeric, Time, nil]
|
|
56
|
+
# @return [void]
|
|
57
|
+
# @raise [TIMEx::Error] when the instance was already closed
|
|
58
|
+
def arm(deadline)
|
|
59
|
+
raise TIMEx::Error, "Wakeup is single-use; construct a fresh instance" if closed?
|
|
60
|
+
|
|
61
|
+
deadline = Deadline.coerce(deadline)
|
|
62
|
+
return if deadline.infinite?
|
|
63
|
+
|
|
64
|
+
ensure_pipe
|
|
65
|
+
@timer = Thread.new do
|
|
66
|
+
remaining = deadline.remaining
|
|
67
|
+
::Kernel.sleep(remaining) if remaining.positive?
|
|
68
|
+
fire(reason: :timeout)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Cancels observers and wakes blocked readers.
|
|
73
|
+
#
|
|
74
|
+
# @param reason [Symbol] opaque reason forwarded to {CancellationToken#cancel}
|
|
75
|
+
# @return [Boolean, nil] result of the internal fire sequence
|
|
76
|
+
def cancel!(reason: :user)
|
|
77
|
+
fire(reason:)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [Boolean] whether {#cancel!} / timeout has fired
|
|
81
|
+
def fired?
|
|
82
|
+
@token.cancelled?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Idempotently closes pipe ends and stops the timer thread.
|
|
86
|
+
#
|
|
87
|
+
# @return [void]
|
|
88
|
+
def close
|
|
89
|
+
@timer&.kill
|
|
90
|
+
@io_mutex.synchronize do
|
|
91
|
+
return if @closed
|
|
92
|
+
|
|
93
|
+
@read_io.close if @read_io && !@read_io.closed?
|
|
94
|
+
@write_io.close if @write_io && !@write_io.closed?
|
|
95
|
+
@closed = true
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
protected
|
|
100
|
+
|
|
101
|
+
# @param deadline [Deadline]
|
|
102
|
+
# @yieldparam deadline [Deadline]
|
|
103
|
+
# @return [Object]
|
|
104
|
+
# @raise [TIMEx::Error] when reused after {#close}
|
|
105
|
+
def run(deadline)
|
|
106
|
+
raise TIMEx::Error, "Wakeup is single-use; construct a fresh instance" if closed?
|
|
107
|
+
|
|
108
|
+
arm(deadline) unless @timer
|
|
109
|
+
yield(deadline)
|
|
110
|
+
ensure
|
|
111
|
+
close
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
# @return [void]
|
|
117
|
+
def ensure_pipe
|
|
118
|
+
@io_mutex.synchronize do
|
|
119
|
+
raise TIMEx::Error, "Wakeup is single-use; construct a fresh instance" if @closed
|
|
120
|
+
return if @read_io
|
|
121
|
+
|
|
122
|
+
@read_io, @write_io = ::IO.pipe
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# @param reason [Symbol]
|
|
127
|
+
# @return [void]
|
|
128
|
+
def fire(reason:)
|
|
129
|
+
return unless @token.cancel(reason:)
|
|
130
|
+
|
|
131
|
+
# Kill the sleeping timer if we were the first to win the race so the
|
|
132
|
+
# thread doesn't dangle in `Kernel.sleep` and write to a closed pipe.
|
|
133
|
+
if @timer && reason != :timeout && @timer.alive?
|
|
134
|
+
begin
|
|
135
|
+
@timer.kill
|
|
136
|
+
rescue StandardError
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
return unless @write_io
|
|
142
|
+
|
|
143
|
+
begin
|
|
144
|
+
@write_io.write_nonblock("!")
|
|
145
|
+
rescue StandardError
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
TIMEx::Registry.register(:wakeup, TIMEx::Strategies::Wakeup)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TIMEx
|
|
4
|
+
module Telemetry
|
|
5
|
+
# Concrete {Telemetry} backends. All adapters receive symbol/string event names
|
|
6
|
+
# and a mutable +payload+ hash for a single logical operation.
|
|
7
|
+
#
|
|
8
|
+
# @see Telemetry.instrument
|
|
9
|
+
# @see Telemetry.emit
|
|
10
|
+
module Adapters
|
|
11
|
+
|
|
12
|
+
# No-op base type documenting the adapter protocol.
|
|
13
|
+
class Base
|
|
14
|
+
|
|
15
|
+
# @param event [Symbol, String]
|
|
16
|
+
# @param payload [Hash{Symbol => Object}]
|
|
17
|
+
# @return [void]
|
|
18
|
+
def start(event:, payload:); end
|
|
19
|
+
|
|
20
|
+
# @param event [Symbol, String]
|
|
21
|
+
# @param payload [Hash{Symbol => Object}]
|
|
22
|
+
# @return [void]
|
|
23
|
+
def finish(event:, payload:); end
|
|
24
|
+
|
|
25
|
+
# Default one-shot implementation pairing {#start} and {#finish}.
|
|
26
|
+
#
|
|
27
|
+
# @param event [Symbol, String]
|
|
28
|
+
# @param payload [Hash{Symbol => Object}]
|
|
29
|
+
# @return [void]
|
|
30
|
+
def emit(event:, payload:)
|
|
31
|
+
start(event:, payload:)
|
|
32
|
+
finish(event:, payload:)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Sentinel adapter used when nothing is configured.
|
|
38
|
+
class Null < Base; end
|
|
39
|
+
|
|
40
|
+
# Emits a single structured log line per {#finish} with a conservative key allowlist.
|
|
41
|
+
class Logger < Base
|
|
42
|
+
|
|
43
|
+
# Keys considered safe to log by default. Application-supplied data
|
|
44
|
+
# like `headers` or block arguments are excluded to avoid leaking
|
|
45
|
+
# secrets/PII through structured logs. `origin` is whitelisted
|
|
46
|
+
# because {Deadline.from_header} enforces `ORIGIN_PATTERN` so the
|
|
47
|
+
# value can only be `[A-Za-z0-9_.-]+`. Pass `extra_keys:` to include
|
|
48
|
+
# additional whitelisted keys.
|
|
49
|
+
DEFAULT_SAFE_KEYS = %i[event strategy outcome deadline_ms elapsed_ms error_class
|
|
50
|
+
soft_ms grace_ms estimate_ms budget_ms
|
|
51
|
+
soft_timeout hard_timeout depth skew_ms origin
|
|
52
|
+
reason].freeze
|
|
53
|
+
|
|
54
|
+
# @param logger [#info] logger receiving +#info+ calls
|
|
55
|
+
# @param extra_keys [Array<Symbol, String>] additional payload keys to include
|
|
56
|
+
def initialize(logger, extra_keys: [])
|
|
57
|
+
super()
|
|
58
|
+
@logger = logger
|
|
59
|
+
@safe_keys = (DEFAULT_SAFE_KEYS + Array(extra_keys).map(&:to_sym)).uniq.freeze
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @param event [Symbol, String]
|
|
63
|
+
# @param payload [Hash{Symbol => Object}]
|
|
64
|
+
# @return [void]
|
|
65
|
+
def finish(event:, payload:)
|
|
66
|
+
@logger.info("[timex] #{event} #{filtered(payload).inspect}")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# @param payload [Hash{Symbol => Object}]
|
|
72
|
+
# @return [Hash{Symbol => Object}]
|
|
73
|
+
def filtered(payload)
|
|
74
|
+
payload.each_with_object({}) do |(k, v), acc|
|
|
75
|
+
acc[k] = v if @safe_keys.include?(k)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Bridges TIMEx events to +ActiveSupport::Notifications+.
|
|
82
|
+
class ActiveSupportNotifications < Base
|
|
83
|
+
|
|
84
|
+
EVENT_PREFIX = "timex."
|
|
85
|
+
|
|
86
|
+
def initialize
|
|
87
|
+
super
|
|
88
|
+
require "active_support/notifications"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @param event [Symbol, String]
|
|
92
|
+
# @param payload [Hash{Symbol => Object}]
|
|
93
|
+
# @return [void]
|
|
94
|
+
def start(event:, payload:)
|
|
95
|
+
instrumenter = ::ActiveSupport::Notifications.instrumenter
|
|
96
|
+
payload[:__asn_token] = instrumenter.start("#{EVENT_PREFIX}#{event}", payload)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @param event [Symbol, String]
|
|
100
|
+
# @param payload [Hash{Symbol => Object}]
|
|
101
|
+
# @return [void]
|
|
102
|
+
def finish(event:, payload:)
|
|
103
|
+
token = payload.delete(:__asn_token)
|
|
104
|
+
return unless token
|
|
105
|
+
|
|
106
|
+
::ActiveSupport::Notifications.instrumenter.finish_with_state(token, "#{EVENT_PREFIX}#{event}", payload)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Bridges TIMEx spans to OpenTelemetry when the gem is available.
|
|
112
|
+
class OpenTelemetry < Base
|
|
113
|
+
|
|
114
|
+
ATTRIBUTE_TYPES = [String, Symbol, Numeric, TrueClass, FalseClass, NilClass].freeze
|
|
115
|
+
|
|
116
|
+
def initialize
|
|
117
|
+
super
|
|
118
|
+
require "opentelemetry/api"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @param event [Symbol, String]
|
|
122
|
+
# @param payload [Hash{Symbol => Object}]
|
|
123
|
+
# @return [void]
|
|
124
|
+
def start(event:, payload:)
|
|
125
|
+
tracer = ::OpenTelemetry.tracer_provider.tracer("timex")
|
|
126
|
+
payload[:__otel_span] = tracer.start_span(
|
|
127
|
+
event,
|
|
128
|
+
attributes: coerce_attributes(payload)
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @param event [Symbol, String]
|
|
133
|
+
# @param payload [Hash{Symbol => Object}]
|
|
134
|
+
# @return [void]
|
|
135
|
+
def finish(event:, payload:)
|
|
136
|
+
span = payload.delete(:__otel_span)
|
|
137
|
+
return unless span
|
|
138
|
+
|
|
139
|
+
span.set_attribute("timex.elapsed_ms", payload[:elapsed_ms]) if payload[:elapsed_ms] && span.respond_to?(:set_attribute)
|
|
140
|
+
|
|
141
|
+
if span.respond_to?(:status=)
|
|
142
|
+
case payload[:outcome]
|
|
143
|
+
when :timeout
|
|
144
|
+
span.status = ::OpenTelemetry::Trace::Status.error("timeout")
|
|
145
|
+
when :error
|
|
146
|
+
span.status = ::OpenTelemetry::Trace::Status.error(payload[:error_class].to_s)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
span.finish
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
# Drops keys with nested or non-OTel-supported values rather than
|
|
156
|
+
# lossy `to_s`-ifying everything.
|
|
157
|
+
#
|
|
158
|
+
# @param payload [Hash{Symbol => Object}]
|
|
159
|
+
# @return [Hash{String => Object}]
|
|
160
|
+
def coerce_attributes(payload)
|
|
161
|
+
payload.each_with_object({}) do |(k, v), acc|
|
|
162
|
+
next if k.to_s.start_with?("__")
|
|
163
|
+
next unless ATTRIBUTE_TYPES.any? { |t| v.is_a?(t) }
|
|
164
|
+
|
|
165
|
+
acc[k.to_s] = v.is_a?(Symbol) ? v.to_s : v
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TIMEx
|
|
4
|
+
# Lightweight instrumentation facade for strategies, composers, and internal
|
|
5
|
+
# events (deadline skew, cancellation observer errors, etc.).
|
|
6
|
+
#
|
|
7
|
+
# Resolution order for {#adapter}: explicit {Telemetry.adapter=} assignment,
|
|
8
|
+
# then {Configuration#telemetry_adapter}, then {Adapters::Null}.
|
|
9
|
+
#
|
|
10
|
+
# @see Telemetry::Adapters
|
|
11
|
+
# @see Configuration#telemetry_adapter=
|
|
12
|
+
module Telemetry
|
|
13
|
+
|
|
14
|
+
@adapter = nil
|
|
15
|
+
@null_adapter = nil
|
|
16
|
+
@strict = false
|
|
17
|
+
|
|
18
|
+
extend self
|
|
19
|
+
|
|
20
|
+
# Resolves the active adapter on each access so runtime config changes apply.
|
|
21
|
+
#
|
|
22
|
+
# @return [#emit, #start, #finish] concrete adapter instance
|
|
23
|
+
def adapter
|
|
24
|
+
return @adapter if @adapter
|
|
25
|
+
|
|
26
|
+
TIMEx.config.telemetry_adapter || (@null_adapter ||= Adapters::Null.new)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Forces a specific adapter (overrides {Configuration#telemetry_adapter} until cleared).
|
|
30
|
+
#
|
|
31
|
+
# @param value [#emit, #start, #finish, nil]
|
|
32
|
+
# @return [#emit, #start, #finish, nil]
|
|
33
|
+
def adapter=(value)
|
|
34
|
+
@adapter = value
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# When +true+, adapter errors propagate instead of being swallowed by {#instrument} / {#emit}.
|
|
38
|
+
#
|
|
39
|
+
# @return [Boolean]
|
|
40
|
+
attr_accessor :strict
|
|
41
|
+
|
|
42
|
+
# Clears memoized adapter state and resets {#strict} to +false+.
|
|
43
|
+
#
|
|
44
|
+
# @return [void]
|
|
45
|
+
def reset!
|
|
46
|
+
@adapter = nil
|
|
47
|
+
@null_adapter = nil
|
|
48
|
+
@strict = false
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Boolean] +true+ when the resolved adapter is {Adapters::Null}
|
|
52
|
+
#
|
|
53
|
+
# @note Lets hot paths skip kwarg-heavy instrumentation when telemetry is disabled.
|
|
54
|
+
def null_adapter?
|
|
55
|
+
adapter.is_a?(Adapters::Null)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Emits a one-shot event (no span pairing).
|
|
59
|
+
#
|
|
60
|
+
# @param event [Symbol, String] logical event name
|
|
61
|
+
# @param payload [Hash{Symbol => Object}] structured attributes
|
|
62
|
+
# @return [void]
|
|
63
|
+
def emit(event:, **payload)
|
|
64
|
+
a = adapter
|
|
65
|
+
return if a.is_a?(Adapters::Null)
|
|
66
|
+
|
|
67
|
+
safely { a.emit(event:, payload:) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Wraps a block with +start+ / +finish+ callbacks and elapsed timing in +payload+.
|
|
71
|
+
#
|
|
72
|
+
# @param event [Symbol, String] logical event name
|
|
73
|
+
# @param base_payload [Hash{Symbol => Object}] initial payload (mutated; see @note)
|
|
74
|
+
# @yieldparam payload [Hash{Symbol => Object}] same object passed to the adapter
|
|
75
|
+
# @return [Object] the block's return value
|
|
76
|
+
#
|
|
77
|
+
# @note Mutates +base_payload+ in place to avoid per-call +dup+ on the hot path.
|
|
78
|
+
def instrument(event:, **base_payload)
|
|
79
|
+
a = adapter
|
|
80
|
+
return yield(base_payload) if a.is_a?(Adapters::Null)
|
|
81
|
+
|
|
82
|
+
# `base_payload` is a fresh hash from the kwarg splat at the call site;
|
|
83
|
+
# the caller can't observe our mutations, so we skip the defensive dup
|
|
84
|
+
# to save an allocation per instrumented call.
|
|
85
|
+
payload = base_payload
|
|
86
|
+
started_ns = Clock.monotonic_ns
|
|
87
|
+
safely { a.start(event:, payload:) }
|
|
88
|
+
begin
|
|
89
|
+
result = yield(payload)
|
|
90
|
+
payload[:outcome] ||= :ok
|
|
91
|
+
result
|
|
92
|
+
rescue StandardError, Expired => e
|
|
93
|
+
payload[:outcome] ||= e.is_a?(Expired) ? :timeout : :error
|
|
94
|
+
payload[:error_class] = e.class.name unless e.is_a?(Expired)
|
|
95
|
+
raise
|
|
96
|
+
end
|
|
97
|
+
ensure
|
|
98
|
+
if started_ns
|
|
99
|
+
payload[:elapsed_ms] = (Clock.monotonic_ns - started_ns) / 1_000_000.0
|
|
100
|
+
safely { a.finish(event:, payload:) }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# @yield adapter callback
|
|
107
|
+
# @return [Object, nil]
|
|
108
|
+
def safely
|
|
109
|
+
yield
|
|
110
|
+
rescue StandardError
|
|
111
|
+
raise if @strict
|
|
112
|
+
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
require_relative "telemetry/adapters"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TIMEx
|
|
4
|
+
# Test helpers for controlling TIMEx time without real sleeps.
|
|
5
|
+
#
|
|
6
|
+
# Installs a {Clock::VirtualClock} on the current thread and exposes helpers to
|
|
7
|
+
# advance it from nested code.
|
|
8
|
+
#
|
|
9
|
+
# @see Clock::VirtualClock
|
|
10
|
+
module Test
|
|
11
|
+
|
|
12
|
+
extend self
|
|
13
|
+
|
|
14
|
+
# Installs a {Clock::VirtualClock} for the block and yields it for manual control.
|
|
15
|
+
#
|
|
16
|
+
# @param start_ns [Integer] initial monotonic nanoseconds for the virtual clock
|
|
17
|
+
# @yieldparam clock [Clock::VirtualClock] the installed virtual clock
|
|
18
|
+
# @return [Object] the block's return value
|
|
19
|
+
#
|
|
20
|
+
# @note Also sets +:timex_test_clock+ so {.advance} can find the active clock
|
|
21
|
+
# without threading the object through call sites.
|
|
22
|
+
def with_virtual_clock(start_ns: 0)
|
|
23
|
+
previous_clock = Thread.current.thread_variable_get(:timex_clock)
|
|
24
|
+
previous_test_clock = Thread.current.thread_variable_get(:timex_test_clock)
|
|
25
|
+
clock = Clock::VirtualClock.new(monotonic_ns: start_ns)
|
|
26
|
+
Thread.current.thread_variable_set(:timex_clock, clock)
|
|
27
|
+
Thread.current.thread_variable_set(:timex_test_clock, clock)
|
|
28
|
+
yield clock
|
|
29
|
+
ensure
|
|
30
|
+
Thread.current.thread_variable_set(:timex_clock, previous_clock)
|
|
31
|
+
Thread.current.thread_variable_set(:timex_test_clock, previous_test_clock)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Advances the active {Clock::VirtualClock} by +seconds+.
|
|
35
|
+
#
|
|
36
|
+
# @param seconds [Numeric] delta in seconds
|
|
37
|
+
# @return [Clock::VirtualClock] the advanced clock
|
|
38
|
+
# @raise [RuntimeError] when called outside {.with_virtual_clock}
|
|
39
|
+
def advance(seconds)
|
|
40
|
+
clock = Thread.current.thread_variable_get(:timex_test_clock) ||
|
|
41
|
+
raise("call inside TIMEx::Test.with_virtual_clock { ... }")
|
|
42
|
+
clock.advance(seconds)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @see .with_virtual_clock
|
|
46
|
+
def freeze_time(&)
|
|
47
|
+
with_virtual_clock(&)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TIMEx
|
|
4
|
+
# Shared +on_timeout:+ dispatcher included by strategies and composers.
|
|
5
|
+
#
|
|
6
|
+
# Centralizes semantics for +:raise+, +:raise_standard+, +:return_nil+,
|
|
7
|
+
# +:result+, and custom +Proc+ handlers so layers cannot drift.
|
|
8
|
+
#
|
|
9
|
+
# @see ON_TIMEOUT_SYMBOLS
|
|
10
|
+
# @see Expired
|
|
11
|
+
# @see Result.timeout
|
|
12
|
+
module TimeoutHandling
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
# Dispatches an {Expired} according to +on_timeout+.
|
|
17
|
+
#
|
|
18
|
+
# @param on_timeout [Symbol, Proc] mode or custom handler
|
|
19
|
+
# @param expired [Expired] the deadline exception
|
|
20
|
+
# @return [Object, nil] handler return value; may raise
|
|
21
|
+
# @raise [Expired] when +on_timeout == :raise+
|
|
22
|
+
# @raise [TimeoutError] when +on_timeout == :raise_standard+
|
|
23
|
+
# @raise [ArgumentError] when +on_timeout+ is unknown
|
|
24
|
+
def handle_timeout(on_timeout, expired)
|
|
25
|
+
case on_timeout
|
|
26
|
+
when :raise then raise expired
|
|
27
|
+
when :raise_standard then raise TimeoutError.from(expired)
|
|
28
|
+
when :return_nil then nil
|
|
29
|
+
when :result then Result.timeout(strategy: self.class.name_symbol, expired:)
|
|
30
|
+
when Proc then on_timeout.call(expired)
|
|
31
|
+
else
|
|
32
|
+
raise ArgumentError,
|
|
33
|
+
"unknown on_timeout: #{on_timeout.inspect} " \
|
|
34
|
+
"(expected one of #{ON_TIMEOUT_SYMBOLS.inspect}, or a Proc)"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/timex.rb
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TIMEx
|
|
4
|
+
|
|
5
|
+
Error = Class.new(StandardError)
|
|
6
|
+
ConfigurationError = Class.new(Error)
|
|
7
|
+
StrategyNotFoundError = Class.new(Error)
|
|
8
|
+
|
|
9
|
+
extend self
|
|
10
|
+
|
|
11
|
+
# Primary entrypoint: runs +block+ under +deadline_or_seconds+ using the resolved strategy.
|
|
12
|
+
#
|
|
13
|
+
# @param deadline_or_seconds [Deadline, Numeric, Time, nil] budget or deadline
|
|
14
|
+
# @param strategy [Symbol, #call, nil] registered key, callable, or +nil+ for default
|
|
15
|
+
# @param auto_check [Boolean, nil] +nil+ uses {Configuration#auto_check_default}
|
|
16
|
+
# @param on_timeout [Symbol, Proc, nil] +nil+ uses {Configuration#default_on_timeout}
|
|
17
|
+
# @param opts [Hash{Symbol => Object}] forwarded to the strategy +#call+
|
|
18
|
+
# @yieldparam deadline [Deadline] coerced deadline passed to the inner block
|
|
19
|
+
# @return [Object] strategy/composer result (including timeout handler results)
|
|
20
|
+
# @raise [ArgumentError] when no block is given
|
|
21
|
+
def call(deadline_or_seconds, strategy: nil, auto_check: nil, on_timeout: nil, **opts, &block)
|
|
22
|
+
raise ArgumentError, "block required" unless block
|
|
23
|
+
|
|
24
|
+
deadline = Deadline.coerce(deadline_or_seconds)
|
|
25
|
+
strategy = Registry.resolve_for_call(strategy)
|
|
26
|
+
cfg = config
|
|
27
|
+
on_timeout ||= cfg.default_on_timeout
|
|
28
|
+
auto_check = cfg.auto_check_default if auto_check.nil?
|
|
29
|
+
runner =
|
|
30
|
+
if auto_check
|
|
31
|
+
->(d) { TIMEx::AutoCheck.run(d) { yield(d) } }
|
|
32
|
+
else
|
|
33
|
+
block
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
strategy.call(
|
|
37
|
+
deadline:,
|
|
38
|
+
on_timeout:,
|
|
39
|
+
**opts,
|
|
40
|
+
&runner
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
alias deadline call
|
|
44
|
+
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
require_relative "timex/version"
|
|
48
|
+
require_relative "timex/on_timeout"
|
|
49
|
+
require_relative "timex/named_component"
|
|
50
|
+
require_relative "timex/expired"
|
|
51
|
+
require_relative "timex/configuration"
|
|
52
|
+
require_relative "timex/clock"
|
|
53
|
+
require_relative "timex/telemetry"
|
|
54
|
+
require_relative "timex/deadline"
|
|
55
|
+
require_relative "timex/result"
|
|
56
|
+
require_relative "timex/cancellation_token"
|
|
57
|
+
require_relative "timex/registry"
|
|
58
|
+
require_relative "timex/timeout_handling"
|
|
59
|
+
require_relative "timex/strategies/base"
|
|
60
|
+
require_relative "timex/strategies/cooperative"
|
|
61
|
+
require_relative "timex/strategies/io"
|
|
62
|
+
require_relative "timex/strategies/unsafe"
|
|
63
|
+
require_relative "timex/strategies/wakeup"
|
|
64
|
+
require_relative "timex/strategies/closeable"
|
|
65
|
+
require_relative "timex/strategies/subprocess"
|
|
66
|
+
require_relative "timex/strategies/ractor"
|
|
67
|
+
require_relative "timex/composers/base"
|
|
68
|
+
require_relative "timex/composers/two_phase"
|
|
69
|
+
require_relative "timex/composers/hedged"
|
|
70
|
+
require_relative "timex/composers/adaptive"
|
|
71
|
+
require_relative "timex/auto_check"
|
|
72
|
+
require_relative "timex/propagation/http_header"
|
|
73
|
+
require_relative "timex/propagation/rack_middleware"
|
|
74
|
+
require_relative "timex/test/virtual_clock"
|
|
75
|
+
|
|
76
|
+
require_relative "generators/timex/install_generator" if defined?(Rails::Generators)
|
|
77
|
+
|
|
78
|
+
# Rails integration is opt-in via the install generator initializer; a Railtie
|
|
79
|
+
# hook is not loaded from this file to keep the core gem free of Rails deps.
|