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,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