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,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TIMEx
|
|
4
|
+
module Propagation
|
|
5
|
+
# Rack middleware: parses inbound {HttpHeader::HEADER_NAME}, stores
|
|
6
|
+
# +env["timex.deadline"]+, optionally clamps and rejects abusive values, and
|
|
7
|
+
# can echo remaining budget on the response.
|
|
8
|
+
#
|
|
9
|
+
# @note The header is **untrusted** on public networks. Combine +max_seconds:+,
|
|
10
|
+
# +max_depth:+, and network controls; see class body for threat summary.
|
|
11
|
+
#
|
|
12
|
+
# @see HttpHeader
|
|
13
|
+
# @see Deadline.from_header
|
|
14
|
+
class RackMiddleware
|
|
15
|
+
|
|
16
|
+
ENV_KEY = "timex.deadline"
|
|
17
|
+
RAW_HEADER_KEY = HttpHeader::RACK_HEADER_KEY
|
|
18
|
+
|
|
19
|
+
# Rack 3 mandates lower-case response header names; Rack 2 (and many
|
|
20
|
+
# 3rd-party middlewares that haven't migrated) still emit canonical
|
|
21
|
+
# case. Pass `header_case: :canonical` to switch the response header
|
|
22
|
+
# names to `Content-Type` / `X-TIMEx-*` for Rack-2-era stacks.
|
|
23
|
+
HEADER_NAMES = {
|
|
24
|
+
rack3: {
|
|
25
|
+
remaining: "x-timex-remaining-ms",
|
|
26
|
+
outcome: "x-timex-outcome",
|
|
27
|
+
content_type: "content-type"
|
|
28
|
+
}.freeze,
|
|
29
|
+
canonical: {
|
|
30
|
+
remaining: "X-TIMEx-Remaining-Ms",
|
|
31
|
+
outcome: "X-TIMEx-Outcome",
|
|
32
|
+
content_type: "Content-Type"
|
|
33
|
+
}.freeze
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
# @param app [#call] inner Rack application
|
|
37
|
+
# @param default_seconds [Numeric, nil] installed when no header is present
|
|
38
|
+
# @param max_seconds [Numeric, nil] clamps inbound deadlines to at most this budget
|
|
39
|
+
# @param max_depth [Integer, nil] rejects requests whose parsed +depth+ exceeds this value
|
|
40
|
+
# @param expose_remaining [Boolean] when +true+, adds remaining-ms response header
|
|
41
|
+
# @param clamp_infinite_to_default [Boolean] when +true+ with +default_seconds+, maps inbound infinite to default
|
|
42
|
+
# @param header_case [:rack3, :canonical] response header casing
|
|
43
|
+
# @raise [ArgumentError] when +header_case+ is unknown
|
|
44
|
+
def initialize(app, default_seconds: nil, max_seconds: nil, max_depth: nil, # rubocop:disable Metrics/ParameterLists
|
|
45
|
+
expose_remaining: false, clamp_infinite_to_default: false,
|
|
46
|
+
header_case: :rack3)
|
|
47
|
+
raise ArgumentError, "header_case must be :rack3 or :canonical" unless HEADER_NAMES.key?(header_case)
|
|
48
|
+
|
|
49
|
+
@app = app
|
|
50
|
+
@default_seconds = default_seconds
|
|
51
|
+
@max_seconds = max_seconds
|
|
52
|
+
@max_depth = max_depth
|
|
53
|
+
@expose_remaining = expose_remaining
|
|
54
|
+
@clamp_infinite_to_default = clamp_infinite_to_default
|
|
55
|
+
@headers = HEADER_NAMES.fetch(header_case)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Security: this header is taken from the inbound HTTP request without
|
|
59
|
+
# authentication. An attacker who can reach this endpoint can send
|
|
60
|
+
# `ms=0` to force an immediate 503, or a large `ms=` value to extend a
|
|
61
|
+
# request's allowed processing window beyond what your server intended.
|
|
62
|
+
# Only mount this middleware on networks where the upstream is trusted
|
|
63
|
+
# (e.g. internal service mesh, signed/authenticated requests).
|
|
64
|
+
#
|
|
65
|
+
# For internet-facing deployments, **always** pass `max_seconds:` so any
|
|
66
|
+
# incoming deadline is clamped to that ceiling, and `max_depth:` to bound
|
|
67
|
+
# propagation hops (example: `use TIMEx::Propagation::RackMiddleware, max_seconds: 30, max_depth: 8`).
|
|
68
|
+
#
|
|
69
|
+
# `Deadline.from_header` also caps untrusted input length at
|
|
70
|
+
# `Deadline::MAX_HEADER_BYTESIZE`, rejects non-finite/negative/very large
|
|
71
|
+
# `ms=` values, and clamps `depth=` at `Deadline::MAX_DEPTH`.
|
|
72
|
+
#
|
|
73
|
+
# `max_depth` is enforced on the *parsed inbound* deadline before
|
|
74
|
+
# `max_seconds` clamping. Clamping via {Deadline#min} can yield a fresh
|
|
75
|
+
# deadline without propagation metadata; checking depth only after clamp
|
|
76
|
+
# would let a client bypass the hop limit with an oversized `ms=`.
|
|
77
|
+
#
|
|
78
|
+
# @param env [Hash{String => Object}]
|
|
79
|
+
# @return [Array(Integer, Hash, #each)] Rack triplet
|
|
80
|
+
def call(env)
|
|
81
|
+
# Distinguish "no header sent" from "header present but unparseable":
|
|
82
|
+
# the latter is suspicious (truncation, smuggling attempt) and
|
|
83
|
+
# deserves a telemetry signal even though we still fall through to
|
|
84
|
+
# `default_seconds` / unbounded handling.
|
|
85
|
+
raw = env[RAW_HEADER_KEY]
|
|
86
|
+
deadline = HttpHeader.from_rack_env(env)
|
|
87
|
+
if raw && !raw.empty? && deadline.nil?
|
|
88
|
+
TIMEx::Telemetry.emit(
|
|
89
|
+
event: "rack.deadline.unparseable",
|
|
90
|
+
bytesize: raw.bytesize
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
if depth_exceeded?(deadline)
|
|
95
|
+
TIMEx::Telemetry.emit(
|
|
96
|
+
event: "rack.deadline.rejected",
|
|
97
|
+
reason: :max_depth_exceeded,
|
|
98
|
+
depth: deadline.depth,
|
|
99
|
+
origin: deadline.origin
|
|
100
|
+
)
|
|
101
|
+
return reject_response("max-depth-exceeded", "Deadline propagation depth exceeded")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
deadline = nil if deadline&.infinite? && @clamp_infinite_to_default && @default_seconds
|
|
105
|
+
deadline = clamp(deadline)
|
|
106
|
+
deadline ||= Deadline.in(@default_seconds) if @default_seconds
|
|
107
|
+
|
|
108
|
+
if deadline
|
|
109
|
+
env[ENV_KEY] = deadline
|
|
110
|
+
env[RAW_HEADER_KEY] = deadline.to_header
|
|
111
|
+
else
|
|
112
|
+
env.delete(RAW_HEADER_KEY)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
if deadline&.expired?
|
|
116
|
+
TIMEx::Telemetry.emit(
|
|
117
|
+
event: "rack.deadline.rejected",
|
|
118
|
+
reason: :expired_on_arrival,
|
|
119
|
+
origin: deadline.origin
|
|
120
|
+
)
|
|
121
|
+
return reject_response("expired-on-arrival", "Deadline expired before request handling")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
status, headers, body = @app.call(env)
|
|
125
|
+
headers = inject_remaining(headers, deadline) if @expose_remaining
|
|
126
|
+
[status, headers, body]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
# @param outcome [String] value for the outcome response header
|
|
132
|
+
# @param body [String] plain-text body (wrapped in a single-element array)
|
|
133
|
+
# @return [Array(Integer, Hash, Array<String>)]
|
|
134
|
+
def reject_response(outcome, body)
|
|
135
|
+
[
|
|
136
|
+
503,
|
|
137
|
+
{
|
|
138
|
+
@headers[:content_type] => "text/plain",
|
|
139
|
+
@headers[:outcome] => outcome
|
|
140
|
+
},
|
|
141
|
+
[body]
|
|
142
|
+
]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @param deadline [Deadline, nil]
|
|
146
|
+
# @return [Deadline, nil]
|
|
147
|
+
def clamp(deadline)
|
|
148
|
+
return deadline unless deadline && @max_seconds
|
|
149
|
+
|
|
150
|
+
deadline.min(Deadline.in(@max_seconds))
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# @param deadline [Deadline, nil]
|
|
154
|
+
# @return [Boolean]
|
|
155
|
+
def depth_exceeded?(deadline)
|
|
156
|
+
return false unless deadline && @max_depth
|
|
157
|
+
|
|
158
|
+
deadline.depth > @max_depth
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# @param headers [Hash, Object]
|
|
162
|
+
# @param deadline [Deadline, nil]
|
|
163
|
+
# @return [Hash, Object]
|
|
164
|
+
def inject_remaining(headers, deadline)
|
|
165
|
+
return headers unless deadline && !deadline.infinite?
|
|
166
|
+
return headers unless headers.is_a?(Hash) || headers.respond_to?(:merge)
|
|
167
|
+
|
|
168
|
+
value = deadline.remaining_ms.round.to_s
|
|
169
|
+
key = @headers[:remaining]
|
|
170
|
+
if headers.frozen?
|
|
171
|
+
headers.merge(key => value)
|
|
172
|
+
else
|
|
173
|
+
headers[key] = value
|
|
174
|
+
headers
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TIMEx
|
|
4
|
+
# Copy-on-write registry of named strategies.
|
|
5
|
+
#
|
|
6
|
+
# Reads are lock-free against the frozen backing hash; writes synchronize and
|
|
7
|
+
# replace the hash atomically. Suited for boot-time registration and per-call
|
|
8
|
+
# resolution on the hot path.
|
|
9
|
+
#
|
|
10
|
+
# @see TIMEx.deadline
|
|
11
|
+
# @see Registry.resolve_for_call
|
|
12
|
+
module Registry
|
|
13
|
+
|
|
14
|
+
@strategies = {}.freeze
|
|
15
|
+
@write_mutex = Mutex.new
|
|
16
|
+
@default_selector = nil
|
|
17
|
+
@cached_default = nil
|
|
18
|
+
@cached_default_key = nil
|
|
19
|
+
|
|
20
|
+
extend self
|
|
21
|
+
|
|
22
|
+
# @param name [Symbol, String] registration key
|
|
23
|
+
# @param strategy [#call] callable strategy object
|
|
24
|
+
# @return [void]
|
|
25
|
+
# @raise [ArgumentError] when +strategy+ does not respond to +#call+
|
|
26
|
+
def register(name, strategy)
|
|
27
|
+
raise ArgumentError, "strategy must respond to :call, got #{strategy.inspect}" unless strategy.respond_to?(:call)
|
|
28
|
+
|
|
29
|
+
@write_mutex.synchronize do
|
|
30
|
+
@strategies = @strategies.merge(name.to_sym => strategy).freeze
|
|
31
|
+
invalidate_default_cache
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param name [Symbol, String]
|
|
36
|
+
# @return [#call]
|
|
37
|
+
# @raise [StrategyNotFoundError] when unknown
|
|
38
|
+
def fetch(name)
|
|
39
|
+
sym = name.to_sym
|
|
40
|
+
strategies = @strategies
|
|
41
|
+
strategies.fetch(sym) do
|
|
42
|
+
raise StrategyNotFoundError, "no strategy registered as #{name.inspect}; " \
|
|
43
|
+
"available: #{strategies.keys.inspect}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Array<Symbol>] known registration keys (snapshot)
|
|
48
|
+
def known
|
|
49
|
+
@strategies.keys
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Resolves a symbol to a registered strategy; passes callables through; +nil+ stays +nil+.
|
|
53
|
+
#
|
|
54
|
+
# @param strategy_or_name [Symbol, #call, nil]
|
|
55
|
+
# @return [#call, nil]
|
|
56
|
+
# @raise [StrategyNotFoundError] when a symbol names an unknown strategy
|
|
57
|
+
def resolve(strategy_or_name)
|
|
58
|
+
case strategy_or_name
|
|
59
|
+
when Symbol then fetch(strategy_or_name)
|
|
60
|
+
when nil then nil
|
|
61
|
+
else strategy_or_name
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Resolves the callable used by {TIMEx.deadline}: the configured default when
|
|
66
|
+
# +strategy+ is +nil+, a registered entry when it is a {Symbol}, otherwise
|
|
67
|
+
# the object must respond to +#call+ (strategy class or instance).
|
|
68
|
+
#
|
|
69
|
+
# @param strategy [Symbol, #call, nil]
|
|
70
|
+
# @return [#call]
|
|
71
|
+
# @raise [StrategyNotFoundError] when +strategy+ names an unknown registration
|
|
72
|
+
# @raise [ArgumentError] when +strategy+ is neither +nil+, a {Symbol}, nor +#call+-able
|
|
73
|
+
def resolve_for_call(strategy)
|
|
74
|
+
case strategy
|
|
75
|
+
when nil then select_default
|
|
76
|
+
when Symbol then fetch(strategy)
|
|
77
|
+
else
|
|
78
|
+
if strategy.respond_to?(:call)
|
|
79
|
+
strategy
|
|
80
|
+
else
|
|
81
|
+
raise ArgumentError,
|
|
82
|
+
"strategy must be a Symbol, Class, or instance responding to " \
|
|
83
|
+
"#call, got #{strategy.inspect}"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Installs or returns the optional default-strategy selector block.
|
|
89
|
+
#
|
|
90
|
+
# @yieldreturn [Symbol, nil] strategy key to resolve, or +nil+ to fall through to config
|
|
91
|
+
# @return [Proc, nil] current selector when called without a block
|
|
92
|
+
def default_selector(&block)
|
|
93
|
+
if block
|
|
94
|
+
@write_mutex.synchronize do
|
|
95
|
+
@default_selector = block
|
|
96
|
+
invalidate_default_cache
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
@default_selector
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Resolves {TIMEx.config} default strategy with optional selector and caching.
|
|
103
|
+
#
|
|
104
|
+
# @return [#call]
|
|
105
|
+
#
|
|
106
|
+
# @note Hot path: caches static {Configuration#default_strategy} resolutions; a
|
|
107
|
+
# configured selector disables the cache because results may vary per call.
|
|
108
|
+
def select_default
|
|
109
|
+
if @default_selector
|
|
110
|
+
sym = @default_selector.call
|
|
111
|
+
return fetch(sym) if sym
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
key = TIMEx.config.default_strategy
|
|
115
|
+
return @cached_default if key == @cached_default_key && @cached_default
|
|
116
|
+
|
|
117
|
+
resolved = fetch(key)
|
|
118
|
+
@cached_default_key = key
|
|
119
|
+
@cached_default = resolved
|
|
120
|
+
resolved
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
# @return [void]
|
|
126
|
+
def invalidate_default_cache
|
|
127
|
+
@cached_default = nil
|
|
128
|
+
@cached_default_key = nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
end
|
|
132
|
+
end
|
data/lib/timex/result.rb
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TIMEx
|
|
4
|
+
# Discriminated outcome from work executed under a deadline when errors are
|
|
5
|
+
# captured instead of raised (+on_timeout: :result+, non-raising strategies, etc.).
|
|
6
|
+
#
|
|
7
|
+
# Instances are frozen at construction and support pattern matching via
|
|
8
|
+
# {#deconstruct} / {#deconstruct_keys}.
|
|
9
|
+
#
|
|
10
|
+
# @see TIMEx.deadline
|
|
11
|
+
class Result
|
|
12
|
+
|
|
13
|
+
OK = :ok
|
|
14
|
+
TIMEOUT = :timeout
|
|
15
|
+
ERROR = :error
|
|
16
|
+
|
|
17
|
+
attr_reader :outcome, :value, :strategy, :elapsed_ms, :error, :deadline_ms
|
|
18
|
+
|
|
19
|
+
# @param outcome [Symbol] one of +OK+, +TIMEOUT+, or +ERROR+
|
|
20
|
+
# @param value [Object, nil] success payload when +outcome == OK+
|
|
21
|
+
# @param strategy [Symbol, nil] component name when known
|
|
22
|
+
# @param elapsed_ms [Numeric, nil] elapsed time in ms when known
|
|
23
|
+
# @param error [Exception, nil] underlying exception for timeout/error paths
|
|
24
|
+
# @param deadline_ms [Numeric, nil] original budget in ms for timeout paths
|
|
25
|
+
# @return [void]
|
|
26
|
+
#
|
|
27
|
+
# @note Freezes +self+ before returning.
|
|
28
|
+
def initialize(outcome:, value: nil, strategy: nil, elapsed_ms: nil, error: nil, deadline_ms: nil) # rubocop:disable Metrics/ParameterLists
|
|
29
|
+
@outcome = outcome
|
|
30
|
+
@value = value
|
|
31
|
+
@strategy = strategy
|
|
32
|
+
@elapsed_ms = elapsed_ms
|
|
33
|
+
@error = error
|
|
34
|
+
@deadline_ms = deadline_ms
|
|
35
|
+
freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param value [Object] success value
|
|
39
|
+
# @param strategy [Symbol, nil]
|
|
40
|
+
# @param elapsed_ms [Numeric, nil]
|
|
41
|
+
# @return [Result] frozen OK result
|
|
42
|
+
def self.ok(value, strategy: nil, elapsed_ms: nil)
|
|
43
|
+
new(outcome: OK, value:, strategy:, elapsed_ms:)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Builds a timeout result, always carrying an {Expired} in +#error+ so {#value!}
|
|
47
|
+
# can re-raise with uniform metadata.
|
|
48
|
+
#
|
|
49
|
+
# @param strategy [Symbol, nil]
|
|
50
|
+
# @param expired [Expired, nil] existing {Expired} (preferred)
|
|
51
|
+
# @param elapsed_ms [Numeric, nil] used with +deadline_ms+ to synthesize {Expired} when +expired+ omitted
|
|
52
|
+
# @param deadline_ms [Numeric, nil] used with +elapsed_ms+ to synthesize {Expired} when +expired+ omitted
|
|
53
|
+
# @return [Result] frozen timeout result
|
|
54
|
+
def self.timeout(strategy:, expired: nil, elapsed_ms: nil, deadline_ms: nil)
|
|
55
|
+
expired ||= Expired.new(
|
|
56
|
+
"deadline expired",
|
|
57
|
+
strategy:,
|
|
58
|
+
deadline_ms:,
|
|
59
|
+
elapsed_ms:
|
|
60
|
+
)
|
|
61
|
+
new(
|
|
62
|
+
outcome: TIMEOUT,
|
|
63
|
+
strategy:,
|
|
64
|
+
error: expired,
|
|
65
|
+
elapsed_ms: elapsed_ms || expired.elapsed_ms,
|
|
66
|
+
deadline_ms: deadline_ms || expired.deadline_ms
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @param error [Exception]
|
|
71
|
+
# @param strategy [Symbol, nil]
|
|
72
|
+
# @param elapsed_ms [Numeric, nil]
|
|
73
|
+
# @return [Result] frozen error result
|
|
74
|
+
# @raise [ArgumentError] when +error+ is not an +Exception+
|
|
75
|
+
def self.error(error, strategy: nil, elapsed_ms: nil)
|
|
76
|
+
raise ArgumentError, "error must be an Exception, got #{error.class}" unless error.is_a?(Exception)
|
|
77
|
+
|
|
78
|
+
new(outcome: ERROR, error:, strategy:, elapsed_ms:)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @return [Boolean]
|
|
82
|
+
def ok? = @outcome == OK
|
|
83
|
+
|
|
84
|
+
# @return [Boolean]
|
|
85
|
+
def timeout? = @outcome == TIMEOUT
|
|
86
|
+
|
|
87
|
+
# @return [Boolean]
|
|
88
|
+
def error? = @outcome == ERROR
|
|
89
|
+
|
|
90
|
+
# Returns the success value or raises the captured exception.
|
|
91
|
+
#
|
|
92
|
+
# @return [Object] +@value+ when {#ok?}
|
|
93
|
+
# @raise [Expired, Exception] when {#timeout?} or {#error?}
|
|
94
|
+
# @raise [Error] when not OK and no +@error+ is present
|
|
95
|
+
def value!
|
|
96
|
+
return @value if ok?
|
|
97
|
+
raise @error if @error
|
|
98
|
+
|
|
99
|
+
raise Error, "result has no value"
|
|
100
|
+
end
|
|
101
|
+
alias unwrap value!
|
|
102
|
+
|
|
103
|
+
# Returns +@value+ when the result is OK, otherwise the +default+ (or the
|
|
104
|
+
# block's return value if a block is given).
|
|
105
|
+
#
|
|
106
|
+
# @param default [Object] fallback when not OK and no block given
|
|
107
|
+
# @yieldparam result [Result] +self+ for inspecting +error+, +strategy+, etc.
|
|
108
|
+
# @return [Object]
|
|
109
|
+
def value_or(default = nil)
|
|
110
|
+
if ok?
|
|
111
|
+
@value
|
|
112
|
+
else
|
|
113
|
+
(block_given? ? yield(self) : default)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
alias unwrap_or value_or
|
|
117
|
+
|
|
118
|
+
# @return [Array(Symbol, Object, Exception, nil)] array shape for pattern matching
|
|
119
|
+
def deconstruct
|
|
120
|
+
[@outcome, @value, @error]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# @param _keys [Array<Symbol>, nil] ignored; present for Ruby pattern-matching protocol
|
|
124
|
+
# @return [Hash{Symbol => Object}] hash shape for pattern matching
|
|
125
|
+
def deconstruct_keys(_keys)
|
|
126
|
+
{
|
|
127
|
+
outcome: @outcome,
|
|
128
|
+
value: @value,
|
|
129
|
+
error: @error,
|
|
130
|
+
strategy: @strategy,
|
|
131
|
+
elapsed_ms: @elapsed_ms,
|
|
132
|
+
deadline_ms: @deadline_ms
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TIMEx
|
|
4
|
+
module Strategies
|
|
5
|
+
# Abstract strategy: runs user code against a {Deadline} and maps {Expired}
|
|
6
|
+
# to +on_timeout:+ behavior via {TimeoutHandling}.
|
|
7
|
+
#
|
|
8
|
+
# Subclasses implement {#run}. Telemetry wraps each invocation when a
|
|
9
|
+
# non-null adapter is configured.
|
|
10
|
+
#
|
|
11
|
+
# @see TIMEx.deadline
|
|
12
|
+
# @see Registry
|
|
13
|
+
class Base
|
|
14
|
+
|
|
15
|
+
include TIMEx::NamedComponent
|
|
16
|
+
include TIMEx::TimeoutHandling
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
|
|
20
|
+
# Convenience constructor: +new(**opts).call(...)+.
|
|
21
|
+
#
|
|
22
|
+
# @param deadline [Deadline, Numeric, Time, nil] budget or absolute deadline
|
|
23
|
+
# @param on_timeout [Symbol, Proc] timeout dispatch mode
|
|
24
|
+
# @param opts [Hash{Symbol => Object}] subclass-specific options forwarded to +#initialize+
|
|
25
|
+
# @yieldparam deadline [Deadline] coerced deadline passed to user block
|
|
26
|
+
# @return [Object] block result or handler return (see {TimeoutHandling})
|
|
27
|
+
def call(deadline:, on_timeout: :raise, **opts, &block)
|
|
28
|
+
new(**opts).call(deadline:, on_timeout:, &block)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Coerces +deadline+, optionally instruments, and runs {#run}.
|
|
34
|
+
#
|
|
35
|
+
# @param deadline [Deadline, Numeric, Time, nil]
|
|
36
|
+
# @param on_timeout [Symbol, Proc]
|
|
37
|
+
# @yieldparam deadline [Deadline]
|
|
38
|
+
# @return [Object]
|
|
39
|
+
def call(deadline:, on_timeout: :raise, &block)
|
|
40
|
+
deadline = Deadline.coerce(deadline)
|
|
41
|
+
|
|
42
|
+
# Resolve the adapter exactly once per call. `Telemetry.adapter` walks
|
|
43
|
+
# `Telemetry.@adapter || TIMEx.config.telemetry_adapter || ...` on
|
|
44
|
+
# every access; we hand the resolved object straight to `instrument`
|
|
45
|
+
# to avoid re-walking it under the hot-path null check.
|
|
46
|
+
adapter = TIMEx::Telemetry.adapter
|
|
47
|
+
return run_unobserved(deadline, on_timeout, &block) if adapter.is_a?(TIMEx::Telemetry::Adapters::Null)
|
|
48
|
+
|
|
49
|
+
deadline_ms = deadline.infinite? ? nil : deadline.remaining_ms.round
|
|
50
|
+
TIMEx::Telemetry.instrument(
|
|
51
|
+
event: "strategy.call",
|
|
52
|
+
strategy: self.class.name_symbol,
|
|
53
|
+
deadline_ms:
|
|
54
|
+
) do |payload|
|
|
55
|
+
run(deadline, &block)
|
|
56
|
+
rescue Expired => e
|
|
57
|
+
payload[:outcome] = :timeout
|
|
58
|
+
handle_timeout(on_timeout, e)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
protected
|
|
63
|
+
|
|
64
|
+
# @param deadline [Deadline]
|
|
65
|
+
# @yieldparam deadline [Deadline]
|
|
66
|
+
# @return [Object]
|
|
67
|
+
# @raise [NotImplementedError] on +Base+
|
|
68
|
+
def run(_deadline)
|
|
69
|
+
raise NotImplementedError
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# Telemetry-free path when the adapter is {Telemetry::Adapters::Null}.
|
|
75
|
+
#
|
|
76
|
+
# @param deadline [Deadline]
|
|
77
|
+
# @param on_timeout [Symbol, Proc]
|
|
78
|
+
# @yieldparam deadline [Deadline]
|
|
79
|
+
# @return [Object]
|
|
80
|
+
def run_unobserved(deadline, on_timeout, &)
|
|
81
|
+
run(deadline, &)
|
|
82
|
+
rescue Expired => e
|
|
83
|
+
handle_timeout(on_timeout, e)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TIMEx
|
|
4
|
+
module Strategies
|
|
5
|
+
# Invokes +close_method+ on a watchdog thread after the deadline so blocking
|
|
6
|
+
# I/O surfaces as +IOError+ / +EBADF+ / +EPIPE+ (etc.), then maps those to
|
|
7
|
+
# {Expired} when the close was timer-driven.
|
|
8
|
+
#
|
|
9
|
+
# @note +close_method+ runs **concurrently** with the user block. It must be
|
|
10
|
+
# safe to call while the block uses the resource; avoid mutual deadlocks on
|
|
11
|
+
# the same mutex.
|
|
12
|
+
#
|
|
13
|
+
# @see Base
|
|
14
|
+
class Closeable < Base
|
|
15
|
+
|
|
16
|
+
# @param resource [Object] object receiving +close_method+
|
|
17
|
+
# @param close_method [Symbol] method name invoked to unblock I/O
|
|
18
|
+
def initialize(resource:, close_method: :close)
|
|
19
|
+
super()
|
|
20
|
+
@resource = resource
|
|
21
|
+
@close_method = close_method
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
protected
|
|
25
|
+
|
|
26
|
+
# @param deadline [Deadline]
|
|
27
|
+
# @yieldparam resource [Object] the configured resource
|
|
28
|
+
# @yieldparam deadline [Deadline]
|
|
29
|
+
# @return [Object]
|
|
30
|
+
# @raise [Expired] when I/O errors follow a timer-driven close
|
|
31
|
+
def run(deadline)
|
|
32
|
+
return yield(@resource, deadline) if deadline.infinite?
|
|
33
|
+
|
|
34
|
+
state = { closed_by_timer: false, block_done: false, mutex: Mutex.new }
|
|
35
|
+
timer = Thread.new do
|
|
36
|
+
remaining = deadline.remaining
|
|
37
|
+
::Kernel.sleep(remaining) if remaining.positive?
|
|
38
|
+
# We claim the right to close under the mutex, but invoke
|
|
39
|
+
# `close_method` OUTSIDE it. A blocking `close` implementation
|
|
40
|
+
# would otherwise hold the mutex while the user's block reaches
|
|
41
|
+
# its ensure clause (which also acquires the mutex), causing
|
|
42
|
+
# deadlock.
|
|
43
|
+
should_close = state[:mutex].synchronize do
|
|
44
|
+
next false if state[:block_done]
|
|
45
|
+
|
|
46
|
+
state[:closed_by_timer] = true
|
|
47
|
+
true
|
|
48
|
+
end
|
|
49
|
+
if should_close
|
|
50
|
+
begin
|
|
51
|
+
@resource.public_send(@close_method)
|
|
52
|
+
rescue StandardError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
begin
|
|
58
|
+
yield(@resource, deadline)
|
|
59
|
+
rescue IOError,
|
|
60
|
+
Errno::EBADF, Errno::EPIPE, Errno::ECONNRESET,
|
|
61
|
+
Errno::ENOTCONN, Errno::ESHUTDOWN => e
|
|
62
|
+
raise unless state[:closed_by_timer]
|
|
63
|
+
|
|
64
|
+
raise deadline.expired_error(
|
|
65
|
+
strategy: :closeable,
|
|
66
|
+
message: "closeable deadline expired (#{e.class})"
|
|
67
|
+
)
|
|
68
|
+
ensure
|
|
69
|
+
state[:mutex].synchronize { state[:block_done] = true }
|
|
70
|
+
if timer.alive?
|
|
71
|
+
timer.kill
|
|
72
|
+
timer.join(0.1)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
TIMEx::Registry.register(:closeable, TIMEx::Strategies::Closeable)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TIMEx
|
|
4
|
+
module Strategies
|
|
5
|
+
# Default strategy: runs the block, then performs a final {#Deadline#check!}
|
|
6
|
+
# so purely CPU-bound work still observes expiry at cooperative points only.
|
|
7
|
+
#
|
|
8
|
+
# @see Base
|
|
9
|
+
class Cooperative < Base
|
|
10
|
+
|
|
11
|
+
protected
|
|
12
|
+
|
|
13
|
+
# @param deadline [Deadline]
|
|
14
|
+
# @yieldparam deadline [Deadline]
|
|
15
|
+
# @return [Object] block result after post-check
|
|
16
|
+
# @raise [Expired] when past deadline after the block returns
|
|
17
|
+
def run(deadline)
|
|
18
|
+
result = yield(deadline)
|
|
19
|
+
deadline.check!(strategy: :cooperative)
|
|
20
|
+
result
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
TIMEx::Registry.register(:cooperative, TIMEx::Strategies::Cooperative)
|