lib/view_model/active_record/cache.rb



# frozen_string_literal: true

require 'iknow_cache'

# Cache for ViewModels that wrap ActiveRecord models.
class ViewModel::ActiveRecord::Cache
  require 'view_model/active_record/cache/cacheable_view'
  require 'view_model/migrator'

  class UncacheableViewModelError < RuntimeError; end

  attr_reader :viewmodel_class

  class << self
    def render_viewmodels_from_cache(viewmodels, migration_versions: {}, locked: false, serialize_context: nil)
      if viewmodels.empty?
        return [], {}
      end

      ids = viewmodels.map(&:id)
      # ideally the roots wouldn't have to all be the same type
      viewmodel_class = viewmodels.first.class
      serialize_context ||= viewmodel_class.new_serialize_context

      render_from_cache(viewmodel_class, ids,
                        initial_viewmodels: viewmodels,
                        migration_versions: migration_versions,
                        locked: locked,
                        serialize_context: serialize_context)
    end

    def render_from_cache(viewmodel_class, ids, initial_viewmodels: nil, migration_versions: {}, locked: false, serialize_context: viewmodel_class.new_serialize_context)
      ignore_existing = false
      begin
        worker = CacheWorker.new(migration_versions: migration_versions, serialize_context: serialize_context, ignore_existing: ignore_existing)
        worker.render_from_cache(viewmodel_class, ids, initial_viewmodels: initial_viewmodels, locked: locked)
      rescue StaleCachedReference
        # If the cache contents contained a unresolvable stale reference, retry
        # while ignoring existing cached values (thereby updating the cache). This
        # will have the side-effect of duplicate Before/AfterVisit callbacks.
        if ignore_existing
          raise
        else
          ignore_existing = true
          retry
        end
      end
    end
  end

  # If cache_group: is specified, it must be a group of a single key: `:id`
  def initialize(viewmodel_class, cache_group: nil)
    @viewmodel_class = viewmodel_class

    @cache_group = cache_group || create_default_cache_group
    @migrated_cache_group = @cache_group.register_child_group(:migrated, :version)

    # /viewname/:id/viewname-currentversion
    @cache = @cache_group.register_cache(cache_name)

    # /viewname/:id/migrated/:oldversion/viewname-currentversion
    @migrated_cache = @migrated_cache_group.register_cache(cache_name)
  end

  # Handles being called with either ids or model/viewmodel objects
  def delete(*ids)
    ids.each do |id|
      id = id.id if id.respond_to?(:id)
      raise ArgumentError.new unless id.is_a?(Numeric) || id.is_a?(String)

      @cache_group.delete_all(@cache.key.new(id))
    end
  end

  def clear
    @cache_group.invalidate_cache_group
  end

  # @deprecated Replaced by class methods
  def fetch_by_viewmodel(viewmodels, migration_versions: {}, locked: false, serialize_context: @viewmodel_class.new_serialize_context)
    ids = viewmodels.map(&:id)
    fetch(ids, initial_viewmodels: viewmodels, migration_versions: migration_versions, locked: locked, serialize_context: serialize_context)
  end

  # @deprecated Replaced by class methods
  def fetch(ids, initial_viewmodels: nil, migration_versions: {}, locked: false, serialize_context: @viewmodel_class.new_serialize_context)
    self.class.render_from_cache(@viewmodel_class, ids,
                                 initial_viewmodels: initial_viewmodels, locked: locked,
                                 migration_versions: migration_versions, serialize_context: serialize_context)
  end

  class StaleCachedReference < StandardError
    def initialize(error)
      super("Cached value contained stale reference: #{error.message}")
    end
  end

  class CacheWorker
    SENTINEL = Object.new
    WorklistEntry = Struct.new(:ref_name, :viewmodel)

    attr_reader :migration_versions, :serialize_context, :resolved_references

    def initialize(migration_versions:, serialize_context:, ignore_existing: false)
      @worklist                = {} # Hash[type_name, Hash[id, WorklistEntry]]
      @resolved_references     = {} # Hash[refname, json]
      @migration_versions      = migration_versions
      @migrated_cache_versions = {}
      @serialize_context       = serialize_context
      @ignore_existing         = ignore_existing
    end

    def render_from_cache(viewmodel_class, ids, initial_viewmodels: nil, locked: false)
      viewmodel_class.transaction do
        root_serializations = Array.new(ids.size)

        # Collect input array positions for each id, allowing duplicates
        positions = ids.each_with_index.with_object({}) do |(id, i), h|
          (h[id] ||= []) << i
        end

        # If duplicates are specified, fetch each only once
        ids = positions.keys

        ids_to_render = ids.to_set

        if viewmodel_class < CacheableView && !@ignore_existing
          # Load existing serializations from the cache
          cached_serializations = load_from_cache(viewmodel_class.viewmodel_cache, ids)
          cached_serializations.each do |id, data|
            positions[id].each do |idx|
              root_serializations[idx] = data
            end
          end

          ids_to_render.subtract(cached_serializations.keys)

          # If initial root viewmodels were provided, call hooks on any
          # viewmodels which were rendered from the cache to ensure that the
          # root is visible (in isolation). Other than this, no traversal
          # callbacks are performed for cache-rendered views. This particularly
          # requires care for references: if a visible view may refer to
          # non-visible cacheable views, those referenced views will not be
          # access control checked.
          initial_viewmodels&.each do |v|
            next unless cached_serializations.has_key?(v.id)
            serialize_context.run_callback(ViewModel::Callbacks::Hook::BeforeVisit, v)
            serialize_context.run_callback(ViewModel::Callbacks::Hook::AfterVisit, v)
          end
        end

        # Render remaining views. If initial viewmodels have been locked, we may
        # use them to serialize from, otherwise we must reload with share lock
        # in find_and_preload.
        available_viewmodels =
          if locked
            initial_viewmodels&.each_with_object({}) do |vm, h|
              h[vm.id] = vm if ids_to_render.include?(vm.id)
            end
          end

        viewmodels = find_and_preload_viewmodels(viewmodel_class, ids_to_render.to_a,
                                                 available_viewmodels: available_viewmodels)

        loaded_serializations = serialize_and_cache(viewmodels)

        loaded_serializations.each do |id, data|
          positions[id].each do |idx|
            root_serializations[idx] = data
          end
        end

        # recursively resolve referenced views
        self.resolve_references!

        [root_serializations, self.resolved_references]
      end
    end

    def resolve_references!
      @serialize_context = serialize_context.for_references

      while @worklist.present?
        type_name, required_entries = @worklist.shift
        viewmodel_class = ViewModel::Registry.for_view_name(type_name)

        required_entries.each do |_id, entry|
          @resolved_references[entry.ref_name] = SENTINEL
        end

        if viewmodel_class < CacheableView && !@ignore_existing
          cached_serializations = load_from_cache(viewmodel_class.viewmodel_cache, required_entries.keys)
          cached_serializations.each do |id, data|
            ref_name = required_entries.delete(id).ref_name
            @resolved_references[ref_name] = data
          end
        end

        # Load remaining entries from database
        available_viewmodels = required_entries.each_with_object({}) do |(id, entry), h|
          h[id] = entry.viewmodel if entry.viewmodel
        end

        viewmodels =
          begin
            find_and_preload_viewmodels(viewmodel_class, required_entries.keys,
                                        available_viewmodels: available_viewmodels)
          rescue ViewModel::DeserializationError::NotFound => e
            # We encountered a reference to an entity that does not exist.
            # If this reference was potentially found in cached data, it
            # could be stale: we can retry without using the cache.
            # If the reference was obtained directly, it indicates invalid
            # data such as an invalid foreign key, and we cannot recover.
            raise StaleCachedReference.new(e)
          end

        loaded_serializations = serialize_and_cache(viewmodels)
        loaded_serializations.each do |id, data|
          ref_name = required_entries[id].ref_name
          @resolved_references[ref_name] = data
        end
      end
    end

    def migrated_cache_version(viewmodel_cache)
      @migrated_cache_versions[viewmodel_cache] ||= viewmodel_cache.migrated_cache_version(migration_versions)
    end

    # Loads the specified entities from the cache and returns a hash of
    # {id=>serialized_view}. Any references encountered are added to the
    # worklist.
    def load_from_cache(viewmodel_cache, ids)
      cached_serializations = viewmodel_cache.load(ids, migrated_cache_version(viewmodel_cache))

      cached_serializations.each_with_object({}) do |(id, cached_serialization), result|
        add_refs_to_worklist(cached_serialization[:ref_cache])
        result[id] = cached_serialization[:data]
      end
    end

    # Serializes the specified preloaded viewmodels and returns a hash of
    # {id=>serialized_view}. If the viewmodel type is cacheable, it will be
    # added to the cache. Any references encountered during serialization are
    # added to the worklist.
    def serialize_and_cache(viewmodels)
      viewmodels.each_with_object({}) do |viewmodel, result|
        builder = Jbuilder.new do |json|
          ViewModel.serialize(viewmodel, json, serialize_context: serialize_context)
        end

        # viewmodels referenced from roots
        referenced_viewmodels = serialize_context.extract_referenced_views!

        if migration_versions.present?
          migrator = ViewModel::DownMigrator.new(migration_versions)

          # This migration isn't able to affect the contents of referenced
          # views, only their presence. The references will be themselves
          # rendered (and migrated) independently later. We mark the dummy
          # references provided to exclude their partial contents from being
          # themselves migrated.
          dummy_references = referenced_viewmodels.transform_values do |ref_vm|
            {
              ViewModel::TYPE_ATTRIBUTE    => ref_vm.class.view_name,
              ViewModel::VERSION_ATTRIBUTE => ref_vm.class.schema_version,
              ViewModel::ID_ATTRIBUTE      => ref_vm.id,
              ViewModel::Migrator::EXCLUDE_FROM_MIGRATION => true,
            }.freeze
          end

          migrator.migrate!({ 'data' => builder.attributes!, 'references' => dummy_references })

          # Removed dummy references can be removed from referenced_viewmodels.
          referenced_viewmodels.keep_if { |k, _| dummy_references.has_key?(k) }

          # Introduced dummy references cannot be handled.
          if dummy_references.keys != referenced_viewmodels.keys
            version = migration_versions[viewmodel.class]
            raise ViewModel::Error.new(
                    status: 500,
                    detail: "Down-migration for cacheable view #{viewmodel.class} to v#{version} must not introduce new shared references")
          end
        end

        data_serialization = builder.target!

        add_viewmodels_to_worklist(referenced_viewmodels)

        if viewmodel.class < CacheableView
          cacheable_references = referenced_viewmodels.transform_values { |vm| cacheable_reference(vm) }
          target_cache = viewmodel.class.viewmodel_cache
          target_cache.store(viewmodel.id, migrated_cache_version(target_cache), data_serialization, cacheable_references)
        end

        result[viewmodel.id] = data_serialization
      end
    end

    # Resolves viewmodels for the provided ids from the database or
    # available_viewmodels and shallowly preloads them.
    def find_and_preload_viewmodels(viewmodel_class, ids, available_viewmodels: nil)
      viewmodels = []

      if available_viewmodels.present?
        ids = ids.reject do |id|
          if (vm = available_viewmodels[id])
            viewmodels << vm
          end
        end
      end

      if ids.present?
        found = viewmodel_class.find(ids,
                                     eager_include: false,
                                     lock: 'FOR SHARE')
        viewmodels.concat(found)
      end

      ViewModel.preload_for_serialization(viewmodels,
                                          include_referenced: false,
                                          lock: 'FOR SHARE')

      viewmodels
    end

    # Store VM references in the cache as viewmodel name + id pairs.
    def cacheable_reference(viewmodel)
      [viewmodel.class.view_name, viewmodel.id]
    end

    def add_refs_to_worklist(cacheable_references)
      cacheable_references.each do |ref_name, (type, id)|
        next if resolved_references.has_key?(ref_name)

        (@worklist[type] ||= {})[id] = WorklistEntry.new(ref_name, nil)
      end
    end

    def add_viewmodels_to_worklist(referenced_viewmodels)
      referenced_viewmodels.each do |ref_name, viewmodel|
        next if resolved_references.has_key?(ref_name)

        (@worklist[viewmodel.class.view_name] ||= {})[viewmodel.id] = WorklistEntry.new(ref_name, viewmodel)
      end
    end
  end

  def cache_for(migration_version)
    if migration_version
      @migrated_cache
    else
      @cache
    end
  end

  def key_for(id, migration_version)
    if migration_version
      @migrated_cache.key.new(id, migration_version)
    else
      @cache.key.new(id)
    end
  end

  def id_for(key)
    key[:id]
  end

  # Save the provided serialization and reference data in the cache
  def store(id, migration_version, data_serialization, ref_cache)
    key = key_for(id, migration_version)
    cache_for(migration_version).write(key, { data: data_serialization, ref_cache: ref_cache })
  end

  def load(ids, migration_version)
    keys = ids.map { |id| key_for(id, migration_version) }
    results = cache_for(migration_version).read_multi(keys)
    results.transform_keys! { |key| id_for(key) }
  end

  def cache_version
    @cache_version ||=
      begin
        versions = @viewmodel_class.deep_schema_version(include_referenced: false)
        ViewModel.schema_hash(versions)
      end
  end

  def migrated_cache_version(migration_versions)
    versions = ViewModel::Migrator.migrated_deep_schema_version(viewmodel_class, migration_versions, include_referenced: false)
    version_hash = ViewModel.schema_hash(versions)

    if version_hash == cache_version
      # no migrations affect this view
      nil
    else
      version_hash
    end
  end

  private

  def create_default_cache_group
    IknowCache.register_group(@viewmodel_class.name, :id)
  end

  # Statically version the cache name based on the (current) deep schema
  # versions of the constituent viewmodels, so that viewmodel changes force
  # invalidation.
  def cache_name
    "vmcache_#{cache_version}"
  end
end