lib/faulty/status.rb
# frozen_string_literal: true
class Faulty
# The status of a circuit
#
# Includes information like the state and locks. Also calculates
# whether a circuit can be run, or if it has failed a threshold.
#
# @!attribute [r] state
# @return [:open, :closed] The stored circuit state. This is always open
# or closed. Half-open is calculated from the current time. For that
# reason, calling state directly should be avoided. Instead use the
# status methods {#open?}, {#closed?}, and {#half_open?}.
# Default `:closed`
# @!attribute [r] lock
# @return [:open, :closed, nil] If the circuit is locked, the state that
# it is locked in. Default `nil`.
# @!attribute [r] opened_at
# @return [Integer, nil] If the circuit is open, the timestamp that it was
# opened. This is not necessarily reset when the circuit is closed.
# Default `nil`.
# @!attribute [r] failure_rate
# @return [Float] A number from 0 to 1 representing the percentage of
# failures for the circuit. For exmaple 0.5 represents a 50% failure rate.
# @!attribute [r] sample_size
# @return [Integer] The number of samples used to calculate the failure rate.
# @!attribute [r] options
# @return [Circuit::Options] The options for the circuit
# @!attribute [r] stub
# @return [Boolean] True if this status is a stub and not calculated from
# the storage backend. Used by {Storage::FaultTolerantProxy} when
# returning the status for an offline storage backend. Default `false`.
Status = Struct.new(
:state,
:lock,
:opened_at,
:failure_rate,
:sample_size,
:options,
:stub
)
class Status
include ImmutableOptions
# The allowed state values
STATES = %i[
open
closed
].freeze
# The allowed lock values
LOCKS = %i[
open
closed
].freeze
# Create a new `Status` from a list of circuit runs
#
# For storage backends that store entries, this automatically calculates
# failure_rate and sample size.
#
# @param entries [Array<Array>] An array of entry tuples. See
# {Circuit#history} for details
# @param hash [Hash] The status attributes minus failure_rate and
# sample_size
# @return [Status]
def self.from_entries(entries, **hash)
window_start = Faulty.current_time - hash[:options].evaluation_window
size = entries.size
i = 0
failures = 0
sample_size = 0
# This is a hot loop, and while is slightly faster than each
while i < size
time, success = entries[i]
i += 1
next unless time > window_start
sample_size += 1
failures += 1 unless success
end
new(hash.merge(
sample_size: sample_size,
failure_rate: sample_size.zero? ? 0.0 : failures.to_f / sample_size
))
end
# Whether the circuit is open
#
# This is mutually exclusive with {#closed?} and {#half_open?}
#
# @return [Boolean] True if open
def open?
state == :open && opened_at + options.cool_down > Faulty.current_time
end
# Whether the circuit is closed
#
# This is mutually exclusive with {#open?} and {#half_open?}
#
# @return [Boolean] True if closed
def closed?
state == :closed
end
# Whether the circuit is half-open
#
# This is mutually exclusive with {#open?} and {#closed?}
#
# @return [Boolean] True if half-open
def half_open?
state == :open && opened_at + options.cool_down <= Faulty.current_time
end
# Whether the circuit is locked open
#
# @return [Boolean] True if locked open
def locked_open?
lock == :open
end
# Whether the circuit is locked closed
#
# @return [Boolean] True if locked closed
def locked_closed?
lock == :closed
end
# Whether the circuit can be run
#
# Takes the circuit state, locks and cooldown into account
#
# @return [Boolean] True if the circuit can be run
def can_run?
return false if locked_open?
closed? || locked_closed? || half_open?
end
# Whether the circuit fails the sample size and rate thresholds
#
# @return [Boolean] True if the circuit fails the thresholds
def fails_threshold?
return false if sample_size < options.sample_threshold
failure_rate >= options.rate_threshold
end
def finalize
raise ArgumentError, "state must be a symbol in #{self.class}::STATES" unless STATES.include?(state)
unless lock.nil? || LOCKS.include?(lock)
raise ArgumentError, "lock must be a symbol in #{self.class}::LOCKS or nil"
end
raise ArgumentError, 'opened_at is required if state is open' if state == :open && opened_at.nil?
end
def required
%i[state failure_rate sample_size options stub]
end
def defaults
{
state: :closed,
failure_rate: 0.0,
sample_size: 0,
stub: false
}
end
end
end