MiniProfiler/rack-mini-profiler

View on GitHub
lib/mini_profiler/storage/memory_store.rb

Summary

Maintainability
A
2 hrs
Test Coverage
# frozen_string_literal: true

require 'securerandom'

module Rack
  class MiniProfiler
    class MemoryStore < AbstractStore

      # Sub-class thread so we have a named thread (useful for debugging in Thread.list).
      class CacheCleanupThread < Thread

        def initialize(interval, cycle, store)
          @store       = store
          @interval    = interval
          @cycle       = cycle
          @cycle_count = 1
          super
        end

        def should_cleanup?
          @cycle_count * @interval >= @cycle
        end

        # We don't want to hit the filesystem every 10s to clean up the cache so we need to do a bit of
        # accounting to avoid sleeping that entire time.  We don't want to sleep for the entire period because
        # it means the thread will stay live in hot deployment scenarios, keeping a potentially large memory
        # graph from being garbage collected upon undeploy.
        def sleepy_run
          cleanup if should_cleanup?
          sleep(@interval)
          increment_cycle
        end

        def cleanup
          @store.cleanup_cache
          @cycle_count = 1
        end

        def cycle_count
          @cycle_count
        end

        def increment_cycle
          @cycle_count += 1
        end
      end

      EXPIRES_IN_SECONDS = 60 * 60 * 24
      CLEANUP_INTERVAL   = 10
      CLEANUP_CYCLE      = 3600

      def initialize(args = nil)
        args ||= {}
        @expires_in_seconds = args.fetch(:expires_in) { EXPIRES_IN_SECONDS }

        @token1, @token2, @cycle_at = nil
        @snapshots_cycle = 0
        @snapshot_groups = {}
        @snapshots = []

        initialize_locks
        initialize_cleanup_thread(args)
      end

      def initialize_locks
        @token_lock           = Mutex.new
        @timer_struct_lock    = Mutex.new
        @user_view_lock       = Mutex.new
        @snapshots_cycle_lock = Mutex.new
        @snapshots_lock       = Mutex.new
        @timer_struct_cache   = {}
        @user_view_cache      = {}
      end

      #FIXME: use weak ref, trouble it may be broken in 1.9 so need to use the 'ref' gem
      def initialize_cleanup_thread(args = {})
        cleanup_interval = args.fetch(:cleanup_interval) { CLEANUP_INTERVAL }
        cleanup_cycle    = args.fetch(:cleanup_cycle)    { CLEANUP_CYCLE }
        t = CacheCleanupThread.new(cleanup_interval, cleanup_cycle, self) do
          until Thread.current[:should_exit] do
            Thread.current.sleepy_run
          end
        end
        at_exit { t[:should_exit] = true }
      end

      def save(page_struct)
        @timer_struct_lock.synchronize {
          @timer_struct_cache[page_struct[:id]] = page_struct
        }
      end

      def load(id)
        @timer_struct_lock.synchronize {
          @timer_struct_cache[id]
        }
      end

      def set_unviewed(user, id)
        @user_view_lock.synchronize {
          @user_view_cache[user] ||= []
          @user_view_cache[user] << id
        }
      end

      def set_viewed(user, id)
        @user_view_lock.synchronize {
          @user_view_cache[user] ||= []
          @user_view_cache[user].delete(id)
        }
      end

      def set_all_unviewed(user, ids)
        @user_view_lock.synchronize {
          @user_view_cache[user] = ids
        }
      end

      def get_unviewed_ids(user)
        @user_view_lock.synchronize {
          @user_view_cache[user]
        }
      end

      def cleanup_cache
        expire_older_than = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @expires_in_seconds) * 1000).to_i
        @timer_struct_lock.synchronize {
          @timer_struct_cache.delete_if { |k, v| v[:started] < expire_older_than }
        }
      end

      def allowed_tokens
        @token_lock.synchronize do

          unless @cycle_at && (@cycle_at > Process.clock_gettime(Process::CLOCK_MONOTONIC))
            @token2 = @token1
            @token1 = SecureRandom.hex
            @cycle_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + Rack::MiniProfiler::AbstractStore::MAX_TOKEN_AGE
          end

          [@token1, @token2].compact

        end
      end

      def should_take_snapshot?(period)
        @snapshots_cycle_lock.synchronize do
          @snapshots_cycle += 1
          if @snapshots_cycle % period == 0
            @snapshots_cycle = 0
            true
          else
            false
          end
        end
      end

      def push_snapshot(page_struct, group_name, config)
        @snapshots_lock.synchronize do
          group = @snapshot_groups[group_name]
          if !group
            @snapshot_groups[group_name] = {
              worst_score: page_struct.duration_ms,
              best_score: page_struct.duration_ms,
              snapshots: [page_struct]
            }
            if @snapshot_groups.size > config.max_snapshot_groups
              group_keys = @snapshot_groups.keys
              group_keys.sort_by! do |key|
                @snapshot_groups[key][:worst_score]
              end
              group_keys.reverse!
              group_keys.pop(group_keys.size - config.max_snapshot_groups)
              @snapshot_groups = @snapshot_groups.slice(*group_keys)
            end
          else
            snapshots = group[:snapshots]
            snapshots << page_struct
            snapshots.sort_by!(&:duration_ms)
            snapshots.reverse!
            if snapshots.size > config.max_snapshots_per_group
              snapshots.pop(snapshots.size - config.max_snapshots_per_group)
            end
            group[:worst_score] = snapshots[0].duration_ms
            group[:best_score] = snapshots[-1].duration_ms
          end
        end
      end

      def fetch_snapshots_overview
        @snapshots_lock.synchronize do
          groups = {}
          @snapshot_groups.each do |name, group|
            groups[name] = {
              worst_score: group[:worst_score],
              best_score: group[:best_score],
              snapshots_count: group[:snapshots].size
            }
          end
          groups
        end
      end

      def fetch_snapshots_group(group_name)
        @snapshots_lock.synchronize do
          group = @snapshot_groups[group_name]
          if group
            group[:snapshots].dup
          else
            []
          end
        end
      end

      def load_snapshot(id, group_name)
        @snapshots_lock.synchronize do
          group = @snapshot_groups[group_name]
          if group
            group[:snapshots].find { |s| s[:id] == id }
          end
        end
      end

      private

      # used in tests only
      def wipe_snapshots_data
        @snapshots_cycle = 0
        @snapshot_groups = {}
      end
    end
  end
end