lib/opal/cache/file_cache.rb

Summary

Maintainability
A
1 hr
Test Coverage
# frozen_string_literal: true

require 'fileutils'
require 'zlib'

module Opal
  module Cache
    class FileCache
      def initialize(dir: nil, max_size: nil)
        @dir = dir || self.class.find_dir
        # Store at most 32MB of cache - de facto this 32MB is larger,
        # as we don't account for inode size for instance. In fact, it's
        # about 50M. Also we run this check before anything runs, so things
        # may go up to 64M or even larger.
        @max_size = max_size || 32 * 1024 * 1024

        tidy_up_cache
      end

      def set(key, data)
        file = cache_filename_for(key)
        out = Marshal.dump(data)

        # Sometimes `Zlib::BufError` gets raised, unsure why, makes no sense, possibly
        # some race condition (see https://github.com/ruby/zlib/issues/49).
        # Limit the number of retries to avoid infinite loops.
        retries = 5
        begin
          out = Zlib.gzip(out, level: 9)
        rescue Zlib::BufError
          warn "\n[Opal]: Zlib::BufError; retrying (#{retries} retries left)"
          retries -= 1
          retry if retries > 0
        end

        File.binwrite(file, out)
      end

      def get(key)
        file = cache_filename_for(key)

        if File.exist?(file)
          FileUtils.touch(file)
          out = File.binread(file)
          out = Zlib.gunzip(out)
          Marshal.load(out) # rubocop:disable Security/MarshalLoad
        end
      rescue Zlib::GzipFile::Error
        nil
      end

      # Remove cache entries that overflow our cache limit... and which
      # were used least recently.
      private def tidy_up_cache
        entries = Dir[@dir + '/*.rbm.gz']
        entries_stats = entries.map { |entry| [entry, File.stat(entry)] }

        size_sum = entries_stats.map { |_entry, stat| stat.size }.sum
        return unless size_sum > @max_size

        # First, we try to get the oldest files first.
        # Then, what's more important, is that we try to get the least
        # recently used files first. Filesystems with relatime or noatime
        # will get this wrong, but it doesn't matter that much, because
        # the previous sort got things "maybe right".
        entries_stats = entries_stats.sort_by { |_entry, stat| [stat.mtime, stat.atime] }

        entries_stats.each do |entry, stat|
          size_sum -= stat.size
          File.unlink(entry)

          # We don't need to work this out anymore - we reached our goal.
          break unless size_sum > @max_size
        end
      rescue Errno::ENOENT
        # Do nothing, this comes from multithreading. We will tidy up at
        # the next chance.
        nil
      end

      # Check if we can robustly mkdir_p a directory.
      def self.dir_writable?(*paths)
        return false unless File.exist?(paths.first)

        until paths.empty?
          dir = File.expand_path(paths.shift, dir)
          ok = File.directory?(dir) && File.writable?(dir) if File.exist?(dir)
        end

        dir if ok
      end

      def self.find_dir
        @find_dir ||= case
                      # Try to write cache into a directory pointed by an environment variable if present
                      when dir = ENV['OPAL_CACHE_DIR']
                        FileUtils.mkdir_p(dir)
                        dir
                      # Otherwise, we write to the place where Opal is installed...
                      # I don't think it's a good location to store cache, so many things can go wrong.
                      # when dir = dir_writable?(Opal.gem_dir, '..', 'tmp', 'cache')
                      #   FileUtils.mkdir_p(dir)
                      #   FileUtils.chmod(0o700, dir)
                      #   dir
                      # Otherwise, ~/.cache/opal...
                      when dir = dir_writable?(Dir.home, '.cache', 'opal')
                        FileUtils.mkdir_p(dir)
                        FileUtils.chmod(0o700, dir)
                        dir
                      # Only /tmp is writable... or isn't it?
                      when (dir = dir_writable?('/tmp', "opal-cache-#{ENV['USER']}")) && File.sticky?('/tmp')
                        FileUtils.mkdir_p(dir)
                        FileUtils.chmod(0o700, dir)
                        dir
                      # No way... we can't write anywhere...
                      else
                        warn "Couldn't find a writable path to store Opal cache. " \
                             'Try setting OPAL_CACHE_DIR environment variable'
                        nil
                      end
      end

      private def cache_filename_for(key)
        "#{@dir}/#{key}.rbm.gz"
      end
    end
  end
end