lib/semian/lru_hash.rb



# frozen_string_literal: true

require "thread"

class LRUHash
  # This LRU (Least Recently Used) hash will allow
  # the cleaning of resources as time goes on.
  # The goal is to remove the least recently used resources
  # everytime we set a new resource. A default window of
  # 5 minutes will allow empty item to stay in the hash
  # for a maximum of 5 minutes
  class NoopMutex
    def synchronize(*)
      yield
    end

    def try_lock
      true
    end

    def unlock
      true
    end

    def locked?
      true
    end

    def owned?
      true
    end
  end

  def keys
    @lock.synchronize { @table.keys }
  end

  def clear
    @lock.synchronize { @table.clear }
  end

  # Create an LRU hash
  #
  # Arguments:
  #   +max_size+ The maximum size of the table
  #   +min_time+ The minimum time in seconds a resource can live in the cache
  #
  # Note:
  #   The +min_time+ is a stronger guarantee than +max_size+. That is, if there are
  #   more than +max_size+ entries in the cache, but they've all been updated more
  #   recently than +min_time+, the garbage collection will not remove them and the
  #   cache can grow without bound. This usually means that you have many active
  #   circuits to disparate endpoints (or your circuit names are bad).
  #   If the max_size is 0, the garbage collection will be very aggressive and
  #   potentially computationally expensive.
  def initialize(max_size: Semian.maximum_lru_size, min_time: Semian.minimum_lru_time)
    @max_size = max_size
    @min_time = min_time
    @table = {}
    @lock =
      if Semian.thread_safe?
        ::Thread::Mutex.new
      else
        ::LRUHash::NoopMutex.new
      end
  end

  def size
    @lock.synchronize { @table.size }
  end

  def count(&block)
    @lock.synchronize { @table.count(&block) }
  end

  def empty?
    @lock.synchronize { @table.empty? }
  end

  def values
    @lock.synchronize { @table.values }
  end

  def set(key, resource)
    @lock.synchronize do
      @table.delete(key)
      @table[key] = resource
      resource.updated_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
    end
    clear_unused_resources if @table.length > @max_size
  end

  # This method uses the property that "Hashes enumerate their values in the
  # order that the corresponding keys were inserted." Deleting a key and
  # re-inserting it effectively moves it to the front of the cache.
  # Update the `updated_at` field so we can use it later do decide if the
  # resource is "in use".
  def get(key)
    @lock.synchronize do
      found = @table.delete(key)
      if found
        @table[key] = found
        found.updated_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
      end
      found
    end
  end

  def delete(key)
    @lock.synchronize do
      @table.delete(key)
    end
  end

  def []=(key, resource)
    set(key, resource)
  end

  def [](key)
    get(key)
  end

  private

  def clear_unused_resources
    payload = {
      size: @table.size,
      examined: 0,
      cleared: 0,
      elapsed: nil,
    }
    timer_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)

    ran = try_synchronize do
      # Clears resources that have not been used
      # in the last 5 minutes (default value of Semian.minimum_lru_time).

      stop_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @min_time
      @table.each do |_, resource|
        payload[:examined] += 1

        # The update times of the resources in the LRU are monotonically increasing,
        # time, so we can stop looking once we find the first resource with an
        # update time after the stop_time.
        break if resource.updated_at > stop_time

        next if resource.in_use?

        resource = @table.delete(resource.name)
        if resource
          payload[:cleared] += 1
          resource.destroy
        end
      end
    end

    if ran
      payload[:elapsed] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - timer_start
      Semian.notify(:lru_hash_gc, self, nil, nil, payload)
    end
  end

  EXCEPTION_NEVER = { Exception => :never }.freeze
  EXCEPTION_IMMEDIATE = { Exception => :immediate }.freeze
  private_constant :EXCEPTION_NEVER
  private_constant :EXCEPTION_IMMEDIATE

  def try_synchronize(&block)
    Thread.handle_interrupt(EXCEPTION_NEVER) do
      return false unless @lock.try_lock

      Thread.handle_interrupt(EXCEPTION_IMMEDIATE, &block)
      true
    ensure
      @lock.unlock if @lock.owned?
    end
  end
end