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,458 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module TIMEx
|
|
6
|
+
|
|
7
|
+
# Immutable deadline: an absolute monotonic expiry (+monotonic_ns+), optional
|
|
8
|
+
# wall alignment (+wall_ns+), and propagation metadata (+origin+, +depth+).
|
|
9
|
+
#
|
|
10
|
+
# Construct via {.in}, {.at_wall}, {.infinite}, {.coerce}, or {.from_header};
|
|
11
|
+
# compare and narrow with {#min}. Instances are frozen at construction.
|
|
12
|
+
#
|
|
13
|
+
# @see Clock
|
|
14
|
+
# @see Expired
|
|
15
|
+
class Deadline
|
|
16
|
+
|
|
17
|
+
HEADER_NAME = "X-TIMEx-Deadline"
|
|
18
|
+
DEFAULT_SKEW_TOLERANCE_MS = 250
|
|
19
|
+
MAX_HEADER_BYTESIZE = 256
|
|
20
|
+
MAX_MS_VALUE = 365 * 24 * 60 * 60 * 1000 # 1 year, prevents overflow attacks
|
|
21
|
+
# Upper bound for {.in} in seconds, aligned with {MAX_MS_VALUE} / 1000 so
|
|
22
|
+
# numeric budgets cannot exceed what untrusted headers can express.
|
|
23
|
+
MAX_BUDGET_SECONDS = MAX_MS_VALUE / 1000.0
|
|
24
|
+
MAX_DEPTH = 64
|
|
25
|
+
ORIGIN_MAX_BYTESIZE = 64
|
|
26
|
+
ORIGIN_PATTERN = /\A[A-Za-z0-9_.-]+\z/
|
|
27
|
+
# Tight cap on iso8601 timestamp length to bound the cost of Rational
|
|
28
|
+
# math in `check_wall_skew`. A canonical TIMEx-emitted stamp is 24 chars
|
|
29
|
+
# (`YYYY-MM-DDTHH:MM:SS.sssZ`); 40 leaves room for offset variants.
|
|
30
|
+
MAX_ISO8601_BYTESIZE = 40
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
|
|
34
|
+
# Builds a relative deadline from now using the active {Clock}.
|
|
35
|
+
#
|
|
36
|
+
# @param seconds [Numeric, nil] duration in seconds; +nil+ means {.infinite}
|
|
37
|
+
# @return [Deadline] finite deadline, or {.infinite} when out of range / non-finite
|
|
38
|
+
def in(seconds)
|
|
39
|
+
return infinite if seconds.nil?
|
|
40
|
+
|
|
41
|
+
if seconds.is_a?(Numeric)
|
|
42
|
+
return demoted_to_infinite(seconds, reason: :non_finite) if seconds.respond_to?(:finite?) && !seconds.finite?
|
|
43
|
+
return demoted_to_infinite(seconds, reason: :over_max_budget) if seconds > MAX_BUDGET_SECONDS
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
product = seconds * Clock::NS_PER_SECOND
|
|
47
|
+
return demoted_to_infinite(seconds, reason: :float_overflow) if product.is_a?(Float) && !product.finite?
|
|
48
|
+
|
|
49
|
+
delta_ns = product.to_i
|
|
50
|
+
new(
|
|
51
|
+
monotonic_ns: Clock.monotonic_ns + delta_ns,
|
|
52
|
+
wall_ns: Clock.wall_ns + delta_ns,
|
|
53
|
+
initial_ns: delta_ns
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Builds a deadline that expires when wall clock reaches +time+ (nanosecond precision).
|
|
58
|
+
#
|
|
59
|
+
# @param time [Time] target wall time (uses +tv_sec+ / +tv_nsec+)
|
|
60
|
+
# @return [Deadline]
|
|
61
|
+
def at_wall(time)
|
|
62
|
+
wall_now = Clock.wall_ns
|
|
63
|
+
target_wall = (time.tv_sec * Clock::NS_PER_SECOND) + time.tv_nsec
|
|
64
|
+
delta = target_wall - wall_now
|
|
65
|
+
new(
|
|
66
|
+
monotonic_ns: Clock.monotonic_ns + delta,
|
|
67
|
+
wall_ns: target_wall,
|
|
68
|
+
initial_ns: delta
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [Deadline] shared infinite sentinel ({INFINITE})
|
|
73
|
+
def infinite
|
|
74
|
+
INFINITE
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Normalizes user input into a {Deadline}.
|
|
78
|
+
#
|
|
79
|
+
# @param value [Deadline, Numeric, Time, nil, Object] existing deadline, seconds, wall +Time+, +nil+ for infinite, etc.
|
|
80
|
+
# @return [Deadline]
|
|
81
|
+
# @raise [ArgumentError] when +value+ cannot be interpreted (e.g. bare Symbol)
|
|
82
|
+
def coerce(value)
|
|
83
|
+
case value
|
|
84
|
+
when Deadline then value
|
|
85
|
+
when Numeric then self.in(value)
|
|
86
|
+
when Time then at_wall(value)
|
|
87
|
+
when nil then infinite
|
|
88
|
+
when Symbol
|
|
89
|
+
raise ArgumentError, "cannot coerce #{value.inspect} into a Deadline " \
|
|
90
|
+
"(did you mean strategy: #{value.inspect}?)"
|
|
91
|
+
else
|
|
92
|
+
raise ArgumentError, "cannot coerce #{value.inspect} into a Deadline"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Parses the wire-format deadline header into a {Deadline}, or +nil+ when
|
|
97
|
+
# malformed, oversized, ambiguous, or rejected for security policy.
|
|
98
|
+
#
|
|
99
|
+
# @param str [String, nil] raw header value (see {HEADER_NAME})
|
|
100
|
+
# @param skew_tolerance_ms [Numeric, nil] override; defaults to {TIMEx.config}
|
|
101
|
+
# @return [Deadline, nil] parsed deadline, or +nil+ on any validation failure
|
|
102
|
+
#
|
|
103
|
+
# @note Rejects combined +ms+ and +wall+ fields, duplicate keys, negative depth,
|
|
104
|
+
# and oversized payloads. Skew detection emits telemetry but does not mutate
|
|
105
|
+
# the parsed deadline.
|
|
106
|
+
def from_header(str, skew_tolerance_ms: nil)
|
|
107
|
+
skew_tolerance_ms ||= TIMEx.config.skew_tolerance_ms
|
|
108
|
+
return nil if str.nil? || str.empty? || str.bytesize > MAX_HEADER_BYTESIZE
|
|
109
|
+
|
|
110
|
+
parts = parse_header_pairs(str)
|
|
111
|
+
return nil if parts.nil? || parts.empty?
|
|
112
|
+
|
|
113
|
+
# Reject ambiguous payloads up front: an attacker who can append to a
|
|
114
|
+
# trusted upstream's header could otherwise smuggle `ms=99999` next to
|
|
115
|
+
# `wall=` to extend the budget, or supply both and rely on parser
|
|
116
|
+
# precedence. Either is a single, well-defined field.
|
|
117
|
+
return nil if parts.key?("ms") && parts.key?("wall")
|
|
118
|
+
|
|
119
|
+
depth = parts["depth"] && Integer(parts["depth"], 10, exception: false)
|
|
120
|
+
# Reject explicitly negative depth so client bugs don't silently coerce
|
|
121
|
+
# to 0 and bypass `max_depth` ceilings on the receiver.
|
|
122
|
+
return nil if depth&.negative?
|
|
123
|
+
|
|
124
|
+
depth = depth.clamp(0, MAX_DEPTH) if depth
|
|
125
|
+
origin = sanitize_origin(parts["origin"])
|
|
126
|
+
|
|
127
|
+
if parts.key?("ms")
|
|
128
|
+
ms = parts["ms"]
|
|
129
|
+
# Even for `ms=inf` we attach origin/depth so middleware can enforce
|
|
130
|
+
# `max_depth` on infinite-budget propagations. Returning the shared
|
|
131
|
+
# sentinel here would drop those, allowing depth-limit bypass.
|
|
132
|
+
if ms == "inf"
|
|
133
|
+
return infinite if origin.nil? && depth.nil?
|
|
134
|
+
|
|
135
|
+
return new(
|
|
136
|
+
monotonic_ns: Float::INFINITY,
|
|
137
|
+
wall_ns: nil,
|
|
138
|
+
origin:,
|
|
139
|
+
depth: depth || 0,
|
|
140
|
+
infinite: true
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
ms_value = Float(ms, exception: false)
|
|
145
|
+
return nil if ms_value.nil? || !ms_value.finite? || ms_value.negative? || ms_value > MAX_MS_VALUE
|
|
146
|
+
|
|
147
|
+
self.in(ms_value / 1000.0).with_meta(origin:, depth:)
|
|
148
|
+
elsif parts.key?("wall")
|
|
149
|
+
wall_raw = parts["wall"]
|
|
150
|
+
return nil if wall_raw.bytesize > MAX_ISO8601_BYTESIZE
|
|
151
|
+
|
|
152
|
+
wall_time = Time.iso8601(wall_raw)
|
|
153
|
+
d = at_wall(wall_time)
|
|
154
|
+
check_wall_skew(d, parts, skew_tolerance_ms)
|
|
155
|
+
d.with_meta(origin:, depth:)
|
|
156
|
+
end
|
|
157
|
+
rescue ArgumentError, TypeError, RangeError
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
# Returns `nil` on duplicate keys so smuggled values like
|
|
164
|
+
# `ms=10;ms=99999` are rejected outright instead of silently
|
|
165
|
+
# last-write-wins.
|
|
166
|
+
def parse_header_pairs(str)
|
|
167
|
+
acc = {}
|
|
168
|
+
str.split(";").each do |kv|
|
|
169
|
+
k, v = kv.strip.split("=", 2)
|
|
170
|
+
next if k.nil? || k.empty? || v.nil?
|
|
171
|
+
|
|
172
|
+
return nil if acc.key?(k)
|
|
173
|
+
|
|
174
|
+
acc[k] = v
|
|
175
|
+
end
|
|
176
|
+
acc
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def demoted_to_infinite(seconds, reason:)
|
|
180
|
+
TIMEx::Telemetry.emit(
|
|
181
|
+
event: "deadline.budget_clamped",
|
|
182
|
+
reason:,
|
|
183
|
+
requested_seconds: seconds.is_a?(Float) ? seconds : seconds.to_f
|
|
184
|
+
)
|
|
185
|
+
infinite
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def sanitize_origin(value)
|
|
189
|
+
return nil if value.nil? || value.empty?
|
|
190
|
+
return nil if value.bytesize > ORIGIN_MAX_BYTESIZE
|
|
191
|
+
return nil unless ORIGIN_PATTERN.match?(value)
|
|
192
|
+
|
|
193
|
+
value
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Compares the upstream's "issued at" timestamp (`now`) against the local
|
|
197
|
+
# wall clock to detect cross-host clock drift. Without `now=` no real
|
|
198
|
+
# skew can be computed, so the guard is a no-op. This emits a telemetry
|
|
199
|
+
# event but does NOT modify the deadline; clamping a propagated wall
|
|
200
|
+
# deadline based on local clock drift would silently extend or shrink
|
|
201
|
+
# the upstream's contract. Operators should react via the telemetry
|
|
202
|
+
# signal (e.g. fix NTP) instead.
|
|
203
|
+
def check_wall_skew(deadline, parts, skew_tolerance_ms)
|
|
204
|
+
now_raw = parts["now"]
|
|
205
|
+
return unless now_raw
|
|
206
|
+
return if now_raw.bytesize > MAX_ISO8601_BYTESIZE
|
|
207
|
+
|
|
208
|
+
# Avoid `Time#to_r * NS_PER_SECOND`: `Rational` allocations dominate
|
|
209
|
+
# this hot path. `tv_sec`/`tv_nsec` give us nanosecond precision
|
|
210
|
+
# without arbitrary-precision math.
|
|
211
|
+
t = Time.iso8601(now_raw)
|
|
212
|
+
upstream_now_ns = (t.tv_sec * Clock::NS_PER_SECOND) + t.tv_nsec
|
|
213
|
+
skew_ms = ((Clock.wall_ns - upstream_now_ns).abs / 1_000_000.0)
|
|
214
|
+
return unless skew_ms > skew_tolerance_ms && deadline.remaining.positive?
|
|
215
|
+
|
|
216
|
+
TIMEx::Telemetry.emit(
|
|
217
|
+
event: "deadline.skew_detected",
|
|
218
|
+
skew_ms:,
|
|
219
|
+
origin: parts["origin"]
|
|
220
|
+
)
|
|
221
|
+
rescue ArgumentError
|
|
222
|
+
nil
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Eagerly initialized after the class is defined (see bottom of file) so
|
|
228
|
+
# concurrent first-callers can't race to construct two distinct sentinels.
|
|
229
|
+
INFINITE = nil # placeholder; overwritten below
|
|
230
|
+
|
|
231
|
+
attr_reader :monotonic_ns, :wall_ns, :origin, :depth, :initial_ns
|
|
232
|
+
|
|
233
|
+
# Creates a frozen deadline. Callers typically use {.in}, {.at_wall}, or {.infinite}.
|
|
234
|
+
#
|
|
235
|
+
# @param monotonic_ns [Integer, Float] absolute monotonic ns at expiration
|
|
236
|
+
# @param wall_ns [Integer, nil] absolute wall ns at expiration
|
|
237
|
+
# @param origin [String, nil] human-readable identifier (already sanitized)
|
|
238
|
+
# @param depth [Integer, nil] propagation hop count (0 .. {MAX_DEPTH})
|
|
239
|
+
# @param infinite [Boolean] true only for the {.infinite} sentinel
|
|
240
|
+
# @param initial_ns [Integer, Float, nil] original budget in nanoseconds at
|
|
241
|
+
# construction time. Captured so {Expired#deadline_ms} can report the
|
|
242
|
+
# *budget* (positive, stable) rather than ad-hoc post-expiry math.
|
|
243
|
+
# @return [void]
|
|
244
|
+
#
|
|
245
|
+
# @note The instance is frozen before returning to the caller.
|
|
246
|
+
def initialize(monotonic_ns:, wall_ns: nil, origin: nil, depth: 0, infinite: false, initial_ns: nil) # rubocop:disable Metrics/ParameterLists
|
|
247
|
+
@monotonic_ns = monotonic_ns
|
|
248
|
+
@wall_ns = wall_ns
|
|
249
|
+
@origin = origin
|
|
250
|
+
@depth = depth || 0
|
|
251
|
+
@infinite = infinite
|
|
252
|
+
@initial_ns = initial_ns
|
|
253
|
+
freeze
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Original budget in milliseconds, or +nil+ for infinite deadlines or when
|
|
257
|
+
# no budget was captured at construction.
|
|
258
|
+
#
|
|
259
|
+
# @return [Float, nil] positive budget in ms when known
|
|
260
|
+
def initial_ms
|
|
261
|
+
return nil if infinite? || @initial_ns.nil?
|
|
262
|
+
|
|
263
|
+
@initial_ns / 1_000_000.0
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Returns a copy with updated propagation metadata (+origin+, +depth+).
|
|
267
|
+
#
|
|
268
|
+
# @param origin [String, nil] new origin (sanitized); +nil+ keeps existing
|
|
269
|
+
# @param depth [Integer, nil] new depth; +nil+ keeps existing
|
|
270
|
+
# @return [Deadline] new frozen instance sharing the same expiry instants
|
|
271
|
+
def with_meta(origin: nil, depth: nil)
|
|
272
|
+
self.class.new(
|
|
273
|
+
monotonic_ns: @monotonic_ns,
|
|
274
|
+
wall_ns: @wall_ns,
|
|
275
|
+
origin: (origin && self.class.send(:sanitize_origin, origin)) || @origin,
|
|
276
|
+
depth: depth || @depth,
|
|
277
|
+
infinite: @infinite,
|
|
278
|
+
initial_ns: @initial_ns
|
|
279
|
+
)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# @return [Boolean] +true+ when this deadline never fires
|
|
283
|
+
def infinite?
|
|
284
|
+
@infinite || @monotonic_ns == Float::INFINITY
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# @return [Float, Integer] nanoseconds remaining before expiry; +Float::INFINITY+ when infinite
|
|
288
|
+
def remaining_ns
|
|
289
|
+
return Float::INFINITY if infinite?
|
|
290
|
+
|
|
291
|
+
@monotonic_ns - Clock.monotonic_ns
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# @return [Float] seconds remaining; +Float::INFINITY+ when infinite
|
|
295
|
+
def remaining
|
|
296
|
+
r = remaining_ns
|
|
297
|
+
return Float::INFINITY if infinite_remaining_float?(r)
|
|
298
|
+
|
|
299
|
+
r / Clock::NS_PER_SECOND.to_f
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# @return [Float] milliseconds remaining; +Float::INFINITY+ when infinite
|
|
303
|
+
def remaining_ms
|
|
304
|
+
r = remaining_ns
|
|
305
|
+
return Float::INFINITY if infinite_remaining_float?(r)
|
|
306
|
+
|
|
307
|
+
r / 1_000_000.0
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# @return [Boolean] +true+ when already past the monotonic expiry (never +true+ when infinite)
|
|
311
|
+
#
|
|
312
|
+
# @note Returns +false+ while {#shield} is active on the current thread.
|
|
313
|
+
def expired?
|
|
314
|
+
return false if infinite?
|
|
315
|
+
return false if Thread.current.thread_variable_get(:timex_shielded)
|
|
316
|
+
|
|
317
|
+
remaining_ns <= 0
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Raises {Expired} when {#expired?} on this thread.
|
|
321
|
+
#
|
|
322
|
+
# @param strategy [Symbol, nil] strategy name for telemetry-style metadata
|
|
323
|
+
# @return [void]
|
|
324
|
+
# @raise [Expired] when past deadline
|
|
325
|
+
def check!(strategy: nil)
|
|
326
|
+
return unless expired?
|
|
327
|
+
|
|
328
|
+
raise expired_error(strategy:)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Builds an {Expired} that consistently reports the *original* budget as
|
|
332
|
+
# `deadline_ms` (positive) and the overshoot/elapsed-past as `elapsed_ms`.
|
|
333
|
+
# Strategies should use this instead of constructing `Expired` ad-hoc to
|
|
334
|
+
# keep `deadline_ms` semantics uniform across the codebase.
|
|
335
|
+
#
|
|
336
|
+
# @param strategy [Symbol, nil] strategy that caught the expiration
|
|
337
|
+
# @param message [String] human-readable message
|
|
338
|
+
# @return [Expired]
|
|
339
|
+
def expired_error(strategy: nil, message: "deadline expired")
|
|
340
|
+
remaining = remaining_ms
|
|
341
|
+
overshoot =
|
|
342
|
+
if infinite_remaining_float?(remaining)
|
|
343
|
+
nil
|
|
344
|
+
else
|
|
345
|
+
(-remaining).round
|
|
346
|
+
end
|
|
347
|
+
Expired.new(
|
|
348
|
+
message,
|
|
349
|
+
strategy:,
|
|
350
|
+
deadline_ms: initial_ms&.round,
|
|
351
|
+
elapsed_ms: overshoot
|
|
352
|
+
)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Earliest-expiring of +self+ and +other+ (finite vs infinite handled).
|
|
356
|
+
#
|
|
357
|
+
# @param other [Deadline, Numeric, Time, nil] coerced via {.coerce}
|
|
358
|
+
# @return [Deadline]
|
|
359
|
+
def min(other)
|
|
360
|
+
return self if other.nil?
|
|
361
|
+
|
|
362
|
+
other = self.class.coerce(other)
|
|
363
|
+
return other if infinite?
|
|
364
|
+
return self if other.infinite?
|
|
365
|
+
|
|
366
|
+
@monotonic_ns <= other.monotonic_ns ? self : other
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Temporarily disables {#expired?} for the current thread (all fibers).
|
|
370
|
+
#
|
|
371
|
+
# @yield work that must not observe expiry checks
|
|
372
|
+
# @return [Object] the block's return value
|
|
373
|
+
#
|
|
374
|
+
# @note Child threads are not shielded; call {#shield} in each thread that
|
|
375
|
+
# should ignore expiry for nested work.
|
|
376
|
+
def shield
|
|
377
|
+
previous = Thread.current.thread_variable_get(:timex_shielded)
|
|
378
|
+
Thread.current.thread_variable_set(:timex_shielded, true)
|
|
379
|
+
yield
|
|
380
|
+
ensure
|
|
381
|
+
Thread.current.thread_variable_set(:timex_shielded, previous)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Serializes this deadline for the +X-TIMEx-Deadline+ header.
|
|
385
|
+
#
|
|
386
|
+
# @param prefer [:remaining, :wall] emit +ms=+ remaining budget or +wall=+ absolute wall target
|
|
387
|
+
# @return [String] wire form (no leading header name)
|
|
388
|
+
def to_header(prefer: :remaining)
|
|
389
|
+
buf = +""
|
|
390
|
+
if infinite?
|
|
391
|
+
buf << "ms=inf"
|
|
392
|
+
# Bare `ms=inf` round-trips to the shared {Deadline.infinite} sentinel
|
|
393
|
+
# (see {.from_header}). Append metadata only when present.
|
|
394
|
+
if @origin || !@depth.zero?
|
|
395
|
+
buf << ";origin=" << @origin if @origin
|
|
396
|
+
buf << ";depth=" << (@depth + 1).clamp(0, MAX_DEPTH).to_s
|
|
397
|
+
end
|
|
398
|
+
return buf
|
|
399
|
+
end
|
|
400
|
+
if prefer == :wall && @wall_ns
|
|
401
|
+
buf << "wall=" << ns_to_iso8601(@wall_ns)
|
|
402
|
+
buf << ";now=" << ns_to_iso8601(Clock.wall_ns)
|
|
403
|
+
else
|
|
404
|
+
buf << "ms=" << remaining_ms.round.to_s
|
|
405
|
+
end
|
|
406
|
+
buf << ";origin=" << @origin if @origin
|
|
407
|
+
buf << ";depth=" << (@depth + 1).clamp(0, MAX_DEPTH).to_s
|
|
408
|
+
buf
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# @param other [Object]
|
|
412
|
+
# @return [Boolean] +true+ when monotonic instant, wall, and propagation metadata match
|
|
413
|
+
#
|
|
414
|
+
# @note Requires +wall_ns+ parity so {#to_header}+(+prefer: :wall+) cannot diverge for equal instances.
|
|
415
|
+
def ==(other)
|
|
416
|
+
other.is_a?(Deadline) &&
|
|
417
|
+
other.monotonic_ns == monotonic_ns &&
|
|
418
|
+
other.wall_ns == wall_ns &&
|
|
419
|
+
other.origin == origin &&
|
|
420
|
+
other.depth == depth
|
|
421
|
+
end
|
|
422
|
+
alias eql? ==
|
|
423
|
+
|
|
424
|
+
# @return [Integer] hash of monotonic instant and metadata
|
|
425
|
+
def hash
|
|
426
|
+
[@monotonic_ns, @wall_ns, @origin, @depth].hash
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# @param other [Object]
|
|
430
|
+
# @return [Boolean] +true+ when +other+ is a {Deadline} with the same monotonic expiry
|
|
431
|
+
def same_instant?(other)
|
|
432
|
+
other.is_a?(Deadline) && other.monotonic_ns == monotonic_ns
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# @return [String] short debug representation
|
|
436
|
+
def inspect
|
|
437
|
+
"#<TIMEx::Deadline remaining=#{infinite? ? 'inf' : "#{remaining_ms.round}ms"} origin=#{@origin.inspect}>"
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
private
|
|
441
|
+
|
|
442
|
+
def infinite_remaining_float?(ns)
|
|
443
|
+
ns.is_a?(Float) && ns.infinite?
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def ns_to_iso8601(ns)
|
|
447
|
+
Time.at(ns / Clock::NS_PER_SECOND, ns % Clock::NS_PER_SECOND, :nsec).utc.iso8601(3)
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
Deadline.send(:remove_const, :INFINITE)
|
|
453
|
+
Deadline.const_set(
|
|
454
|
+
:INFINITE,
|
|
455
|
+
Deadline.new(monotonic_ns: Float::INFINITY, wall_ns: nil, infinite: true).freeze
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TIMEx
|
|
4
|
+
|
|
5
|
+
# Raised when a deadline elapses while a strategy is executing user work.
|
|
6
|
+
#
|
|
7
|
+
# Strategies catch {Expired}, record telemetry, then dispatch through
|
|
8
|
+
# {TimeoutHandling} according to +on_timeout:+.
|
|
9
|
+
#
|
|
10
|
+
# @note Inherits from +Exception+ (not +StandardError+), so +rescue+ without a
|
|
11
|
+
# type and most +rescue StandardError+ handlers will **not** catch this.
|
|
12
|
+
# Rescue +TIMEx::Expired+ explicitly when you wrap raw strategy code. For
|
|
13
|
+
# +StandardError+-compatible behavior use +on_timeout: :raise_standard+,
|
|
14
|
+
# which raises {TimeoutError} with this exception as +#cause+.
|
|
15
|
+
#
|
|
16
|
+
# @see TimeoutError
|
|
17
|
+
# @see TIMEx.deadline
|
|
18
|
+
class Expired < Exception
|
|
19
|
+
|
|
20
|
+
attr_reader :strategy, :deadline_ms, :elapsed_ms
|
|
21
|
+
|
|
22
|
+
# @param message [String] human-readable reason (default is generic)
|
|
23
|
+
# @param strategy [Symbol, nil] strategy name symbol when known
|
|
24
|
+
# @param deadline_ms [Float, Integer, nil] remaining budget in ms when expired
|
|
25
|
+
# @param elapsed_ms [Float, Integer, nil] elapsed time in ms when expired
|
|
26
|
+
def initialize(message = "deadline expired", strategy: nil, deadline_ms: nil, elapsed_ms: nil)
|
|
27
|
+
super(message)
|
|
28
|
+
@strategy = strategy
|
|
29
|
+
@deadline_ms = deadline_ms
|
|
30
|
+
@elapsed_ms = elapsed_ms
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# +StandardError+ raised when a deadline expires and the caller opted into
|
|
36
|
+
# +on_timeout: :raise_standard+.
|
|
37
|
+
#
|
|
38
|
+
# Carries the same +strategy+, +deadline_ms+, and +elapsed_ms+ readers as
|
|
39
|
+
# {Expired}, plus +#original+ pointing at the source {Expired} for inspection
|
|
40
|
+
# or re-raise.
|
|
41
|
+
#
|
|
42
|
+
# @see Expired
|
|
43
|
+
# @see TIMEx.deadline
|
|
44
|
+
class TimeoutError < StandardError
|
|
45
|
+
|
|
46
|
+
attr_reader :strategy, :deadline_ms, :elapsed_ms, :original
|
|
47
|
+
|
|
48
|
+
# Builds a {TimeoutError} from an {Expired}, preserving message and metrics.
|
|
49
|
+
#
|
|
50
|
+
# @param expired [Expired] the deadline exception to wrap
|
|
51
|
+
# @return [TimeoutError] new error with +#original+ set to +expired+
|
|
52
|
+
def self.from(expired)
|
|
53
|
+
new(
|
|
54
|
+
expired.message,
|
|
55
|
+
strategy: expired.strategy,
|
|
56
|
+
deadline_ms: expired.deadline_ms,
|
|
57
|
+
elapsed_ms: expired.elapsed_ms,
|
|
58
|
+
original: expired
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @param message [String] human-readable reason
|
|
63
|
+
# @param strategy [Symbol, nil] strategy name symbol when known
|
|
64
|
+
# @param deadline_ms [Float, Integer, nil] remaining budget in ms when expired
|
|
65
|
+
# @param elapsed_ms [Float, Integer, nil] elapsed time in ms when expired
|
|
66
|
+
# @param original [Expired, nil] the underlying {Expired} when created via {.from}
|
|
67
|
+
def initialize(message = "deadline expired", strategy: nil, deadline_ms: nil, elapsed_ms: nil, original: nil)
|
|
68
|
+
super(message)
|
|
69
|
+
@strategy = strategy
|
|
70
|
+
@deadline_ms = deadline_ms
|
|
71
|
+
@elapsed_ms = elapsed_ms
|
|
72
|
+
@original = original
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TIMEx
|
|
4
|
+
# Mixin that exposes a stable snake_case symbol derived from the including
|
|
5
|
+
# class name. Used for telemetry event payloads and {Result} metadata so
|
|
6
|
+
# strategy and composer names stay consistent without manual registration.
|
|
7
|
+
#
|
|
8
|
+
# @see Strategies::Base
|
|
9
|
+
# @see Composers::Base
|
|
10
|
+
module NamedComponent
|
|
11
|
+
|
|
12
|
+
def self.included(base)
|
|
13
|
+
base.extend(ClassMethods)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Class-level API for {NamedComponent}.
|
|
17
|
+
module ClassMethods
|
|
18
|
+
|
|
19
|
+
# Returns a memoized +Symbol+ derived from the class basename (e.g.
|
|
20
|
+
# +"TIMEx::Strategies::Cooperative"+ → +:cooperative+).
|
|
21
|
+
#
|
|
22
|
+
# @return [Symbol] snake_case component name
|
|
23
|
+
def name_symbol
|
|
24
|
+
@name_symbol ||= (name || "anonymous").split("::").last
|
|
25
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
26
|
+
.downcase
|
|
27
|
+
.to_sym
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TIMEx
|
|
4
|
+
|
|
5
|
+
# Canonical set of symbol modes accepted by +on_timeout:+ on {TIMEx.deadline} and
|
|
6
|
+
# related APIs, plus {Configuration#default_on_timeout=}.
|
|
7
|
+
#
|
|
8
|
+
# Kept in one place so validation, documentation, and {TimeoutHandling} stay
|
|
9
|
+
# aligned.
|
|
10
|
+
#
|
|
11
|
+
# @see Configuration#default_on_timeout=
|
|
12
|
+
# @see TimeoutHandling#handle_timeout
|
|
13
|
+
ON_TIMEOUT_SYMBOLS = %i[raise raise_standard return_nil result].freeze
|
|
14
|
+
|
|
15
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TIMEx
|
|
4
|
+
module Propagation
|
|
5
|
+
# Helpers for reading and writing the {Deadline::HEADER_NAME} wire format on
|
|
6
|
+
# Rack env and generic header maps.
|
|
7
|
+
#
|
|
8
|
+
# @see Deadline.from_header
|
|
9
|
+
# @see Deadline#to_header
|
|
10
|
+
module HttpHeader
|
|
11
|
+
|
|
12
|
+
HEADER_NAME = Deadline::HEADER_NAME
|
|
13
|
+
RACK_HEADER_KEY = "HTTP_X_TIMEX_DEADLINE"
|
|
14
|
+
|
|
15
|
+
extend self
|
|
16
|
+
|
|
17
|
+
# @param env [Hash{String => Object}] Rack environment (+HTTP_*+ keys)
|
|
18
|
+
# @return [Deadline, nil] parsed deadline or +nil+
|
|
19
|
+
def from_rack_env(env)
|
|
20
|
+
Deadline.from_header(env[RACK_HEADER_KEY])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Resolves a deadline from a case-insensitive header map when possible.
|
|
24
|
+
#
|
|
25
|
+
# @param headers [Hash, #key?, #find, nil] header-like collection
|
|
26
|
+
# @return [Deadline, nil]
|
|
27
|
+
def from_headers(headers)
|
|
28
|
+
return Deadline.from_header(nil) if headers.nil?
|
|
29
|
+
|
|
30
|
+
return Deadline.from_header(headers[HEADER_NAME]) if headers.respond_to?(:key?) && headers.key?(HEADER_NAME)
|
|
31
|
+
|
|
32
|
+
pair = headers.find { |k, _| k.to_s.casecmp?(HEADER_NAME) } if headers.respond_to?(:find)
|
|
33
|
+
Deadline.from_header(pair&.last)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Writes +deadline+ into +headers+ under {HEADER_NAME}.
|
|
37
|
+
#
|
|
38
|
+
# @param headers [Hash] mutable header map (mutated in place)
|
|
39
|
+
# @param deadline [Deadline]
|
|
40
|
+
# @param prefer [:remaining, :wall] forwarded to {Deadline#to_header}
|
|
41
|
+
# @return [Hash] +headers+ (same object)
|
|
42
|
+
def inject(headers, deadline, prefer: :remaining)
|
|
43
|
+
headers[HEADER_NAME] = deadline.to_header(prefer:)
|
|
44
|
+
headers
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|