Class: TIMEx::Deadline

Inherits:
Object
  • Object
show all
Defined in:
lib/timex/deadline.rb

Overview

Immutable deadline: an absolute monotonic expiry (+monotonic_ns+), optional wall alignment (+wall_ns+), and propagation metadata (+origin+, depth).

Construct via Deadline.in, Deadline.at_wall, Deadline.infinite, Deadline.coerce, or Deadline.from_header; compare and narrow with #min. Instances are frozen at construction.

See Also:

Constant Summary collapse

HEADER_NAME =
"X-TIMEx-Deadline"
DEFAULT_SKEW_TOLERANCE_MS =
250
MAX_HEADER_BYTESIZE =
256
MAX_MS_VALUE =

1 year, prevents overflow attacks

365 * 24 * 60 * 60 * 1000
MAX_BUDGET_SECONDS =

Upper bound for in in seconds, aligned with MAX_MS_VALUE / 1000 so numeric budgets cannot exceed what untrusted headers can express.

MAX_MS_VALUE / 1000.0
MAX_DEPTH =
64
ORIGIN_MAX_BYTESIZE =
64
ORIGIN_PATTERN =
/\A[A-Za-z0-9_.-]+\z/
MAX_ISO8601_BYTESIZE =

Tight cap on iso8601 timestamp length to bound the cost of Rational math in check_wall_skew. A canonical TIMEx-emitted stamp is 24 chars (YYYY-MM-DDTHH:MM:SS.sssZ); 40 leaves room for offset variants.

40
INFINITE =

Eagerly initialized after the class is defined (see bottom of file) so concurrent first-callers can't race to construct two distinct sentinels.

nil

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(monotonic_ns:, wall_ns: nil, origin: nil, depth: 0, infinite: false, initial_ns: nil) ⇒ void

Note:

The instance is frozen before returning to the caller.

Creates a frozen deadline. Callers typically use in, at_wall, or infinite.

Parameters:

  • monotonic_ns (Integer, Float)

    absolute monotonic ns at expiration

  • wall_ns (Integer, nil) (defaults to: nil)

    absolute wall ns at expiration

  • origin (String, nil) (defaults to: nil)

    human-readable identifier (already sanitized)

  • depth (Integer, nil) (defaults to: 0)

    propagation hop count (0 .. MAX_DEPTH)

  • infinite (Boolean) (defaults to: false)

    true only for the infinite sentinel

  • initial_ns (Integer, Float, nil) (defaults to: nil)

    original budget in nanoseconds at construction time. Captured so Expired#deadline_ms can report the budget (positive, stable) rather than ad-hoc post-expiry math.



246
247
248
249
250
251
252
253
254
# File 'lib/timex/deadline.rb', line 246

def initialize(monotonic_ns:, wall_ns: nil, origin: nil, depth: 0, infinite: false, initial_ns: nil) # rubocop:disable Metrics/ParameterLists
  @monotonic_ns = monotonic_ns
  @wall_ns = wall_ns
  @origin = origin
  @depth = depth || 0
  @infinite = infinite
  @initial_ns = initial_ns
  freeze
end

Instance Attribute Details

#depthObject (readonly)

placeholder; overwritten below



231
232
233
# File 'lib/timex/deadline.rb', line 231

def depth
  @depth
end

#initial_nsObject (readonly)

placeholder; overwritten below



231
232
233
# File 'lib/timex/deadline.rb', line 231

def initial_ns
  @initial_ns
end

#monotonic_nsObject (readonly)

placeholder; overwritten below



231
232
233
# File 'lib/timex/deadline.rb', line 231

def monotonic_ns
  @monotonic_ns
end

#originObject (readonly)

placeholder; overwritten below



231
232
233
# File 'lib/timex/deadline.rb', line 231

def origin
  @origin
end

#wall_nsObject (readonly)

placeholder; overwritten below



231
232
233
# File 'lib/timex/deadline.rb', line 231

def wall_ns
  @wall_ns
end

Class Method Details

.at_wall(time) ⇒ Deadline

Builds a deadline that expires when wall clock reaches time (nanosecond precision).

Parameters:

  • time (Time)

    target wall time (uses tv_sec / tv_nsec)

Returns:



61
62
63
64
65
66
67
68
69
70
# File 'lib/timex/deadline.rb', line 61

def at_wall(time)
  wall_now = Clock.wall_ns
  target_wall = (time.tv_sec * Clock::NS_PER_SECOND) + time.tv_nsec
  delta = target_wall - wall_now
  new(
    monotonic_ns: Clock.monotonic_ns + delta,
    wall_ns: target_wall,
    initial_ns: delta
  )
end

.coerce(value) ⇒ Deadline

Normalizes user input into a TIMEx::Deadline.

Parameters:

  • value (Deadline, Numeric, Time, nil, Object)

    existing deadline, seconds, wall Time, nil for infinite, etc.

Returns:

Raises:

  • (ArgumentError)

    when value cannot be interpreted (e.g. bare Symbol)



82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/timex/deadline.rb', line 82

def coerce(value)
  case value
  when Deadline then value
  when Numeric then self.in(value)
  when Time then at_wall(value)
  when nil then infinite
  when Symbol
    raise ArgumentError, "cannot coerce #{value.inspect} into a Deadline " \
                         "(did you mean strategy: #{value.inspect}?)"
  else
    raise ArgumentError, "cannot coerce #{value.inspect} into a Deadline"
  end
end

.from_header(str, skew_tolerance_ms: nil) ⇒ Deadline?

Note:

Rejects combined ms and wall fields, duplicate keys, negative depth, and oversized payloads. Skew detection emits telemetry but does not mutate the parsed deadline.

Parses the wire-format deadline header into a TIMEx::Deadline, or nil when malformed, oversized, ambiguous, or rejected for security policy.

Parameters:

  • str (String, nil)

    raw header value (see HEADER_NAME)

  • skew_tolerance_ms (Numeric, nil) (defaults to: nil)

    override; defaults to TIMEx.config

Returns:

  • (Deadline, nil)

    parsed deadline, or nil on any validation failure



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
# File 'lib/timex/deadline.rb', line 106

def from_header(str, skew_tolerance_ms: nil)
  skew_tolerance_ms ||= TIMEx.config.skew_tolerance_ms
  return nil if str.nil? || str.empty? || str.bytesize > MAX_HEADER_BYTESIZE

  parts = parse_header_pairs(str)
  return nil if parts.nil? || parts.empty?

  # Reject ambiguous payloads up front: an attacker who can append to a
  # trusted upstream's header could otherwise smuggle `ms=99999` next to
  # `wall=` to extend the budget, or supply both and rely on parser
  # precedence. Either is a single, well-defined field.
  return nil if parts.key?("ms") && parts.key?("wall")

  depth = parts["depth"] && Integer(parts["depth"], 10, exception: false)
  # Reject explicitly negative depth so client bugs don't silently coerce
  # to 0 and bypass `max_depth` ceilings on the receiver.
  return nil if depth&.negative?

  depth = depth.clamp(0, MAX_DEPTH) if depth
  origin = sanitize_origin(parts["origin"])

  if parts.key?("ms")
    ms = parts["ms"]
    # Even for `ms=inf` we attach origin/depth so middleware can enforce
    # `max_depth` on infinite-budget propagations. Returning the shared
    # sentinel here would drop those, allowing depth-limit bypass.
    if ms == "inf"
      return infinite if origin.nil? && depth.nil?

      return new(
        monotonic_ns: Float::INFINITY,
        wall_ns: nil,
        origin:,
        depth: depth || 0,
        infinite: true
      )
    end

    ms_value = Float(ms, exception: false)
    return nil if ms_value.nil? || !ms_value.finite? || ms_value.negative? || ms_value > MAX_MS_VALUE

    self.in(ms_value / 1000.0).with_meta(origin:, depth:)
  elsif parts.key?("wall")
    wall_raw = parts["wall"]
    return nil if wall_raw.bytesize > MAX_ISO8601_BYTESIZE

    wall_time = Time.iso8601(wall_raw)
    d = at_wall(wall_time)
    check_wall_skew(d, parts, skew_tolerance_ms)
    d.with_meta(origin:, depth:)
  end
rescue ArgumentError, TypeError, RangeError
  nil
end

.in(seconds) ⇒ Deadline

Builds a relative deadline from now using the active Clock.

Parameters:

  • seconds (Numeric, nil)

    duration in seconds; nil means infinite

Returns:



38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'lib/timex/deadline.rb', line 38

def in(seconds)
  return infinite if seconds.nil?

  if seconds.is_a?(Numeric)
    return demoted_to_infinite(seconds, reason: :non_finite) if seconds.respond_to?(:finite?) && !seconds.finite?
    return demoted_to_infinite(seconds, reason: :over_max_budget) if seconds > MAX_BUDGET_SECONDS
  end

  product = seconds * Clock::NS_PER_SECOND
  return demoted_to_infinite(seconds, reason: :float_overflow) if product.is_a?(Float) && !product.finite?

  delta_ns = product.to_i
  new(
    monotonic_ns: Clock.monotonic_ns + delta_ns,
    wall_ns: Clock.wall_ns + delta_ns,
    initial_ns: delta_ns
  )
end

.infiniteDeadline

Returns shared infinite sentinel (INFINITE).

Returns:



73
74
75
# File 'lib/timex/deadline.rb', line 73

def infinite
  INFINITE
end

Instance Method Details

#==(other) ⇒ Boolean Also known as: eql?

Note:

Requires wall_ns parity so #to_header+(+prefer: :wall+) cannot diverge for equal instances.

Returns true when monotonic instant, wall, and propagation metadata match.

Parameters:

  • other (Object)

Returns:

  • (Boolean)

    true when monotonic instant, wall, and propagation metadata match



415
416
417
418
419
420
421
# File 'lib/timex/deadline.rb', line 415

def ==(other)
  other.is_a?(Deadline) &&
    other.monotonic_ns == monotonic_ns &&
    other.wall_ns == wall_ns &&
    other.origin == origin &&
    other.depth == depth
end

#check!(strategy: nil) ⇒ void

This method returns an undefined value.

Raises Expired when #expired? on this thread.

Parameters:

  • strategy (Symbol, nil) (defaults to: nil)

    strategy name for telemetry-style metadata

Raises:



325
326
327
328
329
# File 'lib/timex/deadline.rb', line 325

def check!(strategy: nil)
  return unless expired?

  raise expired_error(strategy:)
end

#expired?Boolean

Note:

Returns false while #shield is active on the current thread.

Returns true when already past the monotonic expiry (never true when infinite).

Returns:

  • (Boolean)

    true when already past the monotonic expiry (never true when infinite)



313
314
315
316
317
318
# File 'lib/timex/deadline.rb', line 313

def expired?
  return false if infinite?
  return false if Thread.current.thread_variable_get(:timex_shielded)

  remaining_ns <= 0
end

#expired_error(strategy: nil, message: "deadline expired") ⇒ Expired

Builds an Expired that consistently reports the original budget as deadline_ms (positive) and the overshoot/elapsed-past as elapsed_ms. Strategies should use this instead of constructing Expired ad-hoc to keep deadline_ms semantics uniform across the codebase.

Parameters:

  • strategy (Symbol, nil) (defaults to: nil)

    strategy that caught the expiration

  • message (String) (defaults to: "deadline expired")

    human-readable message

Returns:



339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
# File 'lib/timex/deadline.rb', line 339

def expired_error(strategy: nil, message: "deadline expired")
  remaining = remaining_ms
  overshoot =
    if infinite_remaining_float?(remaining)
      nil
    else
      (-remaining).round
    end
  Expired.new(
    message,
    strategy:,
    deadline_ms: initial_ms&.round,
    elapsed_ms: overshoot
  )
end

#hashInteger

Returns hash of monotonic instant and metadata.

Returns:

  • (Integer)

    hash of monotonic instant and metadata



425
426
427
# File 'lib/timex/deadline.rb', line 425

def hash
  [@monotonic_ns, @wall_ns, @origin, @depth].hash
end

#infinite?Boolean

Returns true when this deadline never fires.

Returns:

  • (Boolean)

    true when this deadline never fires



283
284
285
# File 'lib/timex/deadline.rb', line 283

def infinite?
  @infinite || @monotonic_ns == Float::INFINITY
end

#initial_msFloat?

Original budget in milliseconds, or nil for infinite deadlines or when no budget was captured at construction.

Returns:

  • (Float, nil)

    positive budget in ms when known



260
261
262
263
264
# File 'lib/timex/deadline.rb', line 260

def initial_ms
  return nil if infinite? || @initial_ns.nil?

  @initial_ns / 1_000_000.0
end

#inspectString

Returns short debug representation.

Returns:

  • (String)

    short debug representation



436
437
438
# File 'lib/timex/deadline.rb', line 436

def inspect
  "#<TIMEx::Deadline remaining=#{infinite? ? 'inf' : "#{remaining_ms.round}ms"} origin=#{@origin.inspect}>"
end

#min(other) ⇒ Deadline

Earliest-expiring of self and other (finite vs infinite handled).

Parameters:

Returns:



359
360
361
362
363
364
365
366
367
# File 'lib/timex/deadline.rb', line 359

def min(other)
  return self if other.nil?

  other = self.class.coerce(other)
  return other if infinite?
  return self if other.infinite?

  @monotonic_ns <= other.monotonic_ns ? self : other
end

#remainingFloat

Returns seconds remaining; Float::INFINITY when infinite.

Returns:

  • (Float)

    seconds remaining; Float::INFINITY when infinite



295
296
297
298
299
300
# File 'lib/timex/deadline.rb', line 295

def remaining
  r = remaining_ns
  return Float::INFINITY if infinite_remaining_float?(r)

  r / Clock::NS_PER_SECOND.to_f
end

#remaining_msFloat

Returns milliseconds remaining; Float::INFINITY when infinite.

Returns:

  • (Float)

    milliseconds remaining; Float::INFINITY when infinite



303
304
305
306
307
308
# File 'lib/timex/deadline.rb', line 303

def remaining_ms
  r = remaining_ns
  return Float::INFINITY if infinite_remaining_float?(r)

  r / 1_000_000.0
end

#remaining_nsFloat, Integer

Returns nanoseconds remaining before expiry; Float::INFINITY when infinite.

Returns:

  • (Float, Integer)

    nanoseconds remaining before expiry; Float::INFINITY when infinite



288
289
290
291
292
# File 'lib/timex/deadline.rb', line 288

def remaining_ns
  return Float::INFINITY if infinite?

  @monotonic_ns - Clock.monotonic_ns
end

#same_instant?(other) ⇒ Boolean

Returns true when other is a TIMEx::Deadline with the same monotonic expiry.

Parameters:

  • other (Object)

Returns:

  • (Boolean)

    true when other is a TIMEx::Deadline with the same monotonic expiry



431
432
433
# File 'lib/timex/deadline.rb', line 431

def same_instant?(other)
  other.is_a?(Deadline) && other.monotonic_ns == monotonic_ns
end

#shield { ... } ⇒ Object

Note:

Child threads are not shielded; call #shield in each thread that should ignore expiry for nested work.

Temporarily disables #expired? for the current thread (all fibers).

Yields:

  • work that must not observe expiry checks

Returns:

  • (Object)

    the block's return value



376
377
378
379
380
381
382
# File 'lib/timex/deadline.rb', line 376

def shield
  previous = Thread.current.thread_variable_get(:timex_shielded)
  Thread.current.thread_variable_set(:timex_shielded, true)
  yield
ensure
  Thread.current.thread_variable_set(:timex_shielded, previous)
end

#to_header(prefer: :remaining) ⇒ String

Serializes this deadline for the X-TIMEx-Deadline header.

Parameters:

  • prefer (:remaining, :wall) (defaults to: :remaining)

    emit ms= remaining budget or wall= absolute wall target

Returns:

  • (String)

    wire form (no leading header name)



388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
# File 'lib/timex/deadline.rb', line 388

def to_header(prefer: :remaining)
  buf = +""
  if infinite?
    buf << "ms=inf"
    # Bare `ms=inf` round-trips to the shared {Deadline.infinite} sentinel
    # (see {.from_header}). Append metadata only when present.
    if @origin || !@depth.zero?
      buf << ";origin=" << @origin if @origin
      buf << ";depth=" << (@depth + 1).clamp(0, MAX_DEPTH).to_s
    end
    return buf
  end
  if prefer == :wall && @wall_ns
    buf << "wall=" << ns_to_iso8601(@wall_ns)
    buf << ";now=" << ns_to_iso8601(Clock.wall_ns)
  else
    buf << "ms=" << remaining_ms.round.to_s
  end
  buf << ";origin=" << @origin if @origin
  buf << ";depth=" << (@depth + 1).clamp(0, MAX_DEPTH).to_s
  buf
end

#with_meta(origin: nil, depth: nil) ⇒ Deadline

Returns a copy with updated propagation metadata (+origin+, depth).

Parameters:

  • origin (String, nil) (defaults to: nil)

    new origin (sanitized); nil keeps existing

  • depth (Integer, nil) (defaults to: nil)

    new depth; nil keeps existing

Returns:

  • (Deadline)

    new frozen instance sharing the same expiry instants



271
272
273
274
275
276
277
278
279
280
# File 'lib/timex/deadline.rb', line 271

def with_meta(origin: nil, depth: nil)
  self.class.new(
    monotonic_ns: @monotonic_ns,
    wall_ns: @wall_ns,
    origin: (origin && self.class.send(:sanitize_origin, origin)) || @origin,
    depth: depth || @depth,
    infinite: @infinite,
    initial_ns: @initial_ns
  )
end