require "expiring_memoize/version"

module ExpiringMemoize
  # Memoize a nullary method.
  # @param name [Symbol] method to memoize
  # @option ttl [Number] time to live of the value, in seconds
  def memoize name, ttl: Float::INFINITY
    original = instance_method name
    raise ArgumentError, 'only nullary methods supported' unless original.arity.zero?

    define_method name do
      data = ((@_expiring_memoize_data ||= {})[name] ||= {})
      loop do
        # no need to synchronize here -- worst case,
        # we return a value fresher than expected.
        # (plus, global interpreter lock in MRI makes it safe regardless)
        unless (ts = data[:timestamp]) && (ExpiringMemoize.gettime - ts) < ttl
          # value is stale, race to fetch
          mutex ||= (data[:mutex] ||= Mutex.new)
          if mutex.try_lock
            # our thread won the race, let's get the value
            begin
              data[:value] = original.bind(self).call
              data[:timestamp] = ExpiringMemoize.gettime
            ensure
              mutex.unlock
            end
          else
            # our thread lost, block on the mutex and try again
            mutex.synchronize {}
            next
          end
        end
        return data[:value]
      end
    end
  end

  # @nodoc
  def self.gettime
    Process.clock_gettime Process::CLOCK_MONOTONIC_COARSE
  end
end