app/models/spree_cm_commissioner/redis_stock/cached_inventory_items_builder.rb



module SpreeCmCommissioner
  module RedisStock
    class CachedInventoryItemsBuilder
      attr_reader :inventory_items

      def initialize(inventory_items)
        @inventory_items = inventory_items
      end

      # output: [ CachedInventoryItem(...), CachedInventoryItem(...) ]
      def call
        keys = inventory_items.map { |item| "inventory:#{item.id}" }
        return [] unless keys.any?

        counts = SpreeCmCommissioner.redis_pool.with { |redis| redis.mget(*keys) }
        inventory_items.map.with_index do |inventory_item, i|
          ::SpreeCmCommissioner::CachedInventoryItem.new(
            inventory_key: keys[i],
            active: inventory_item.active?,
            quantity_available: cache_inventory(keys[i], inventory_item, counts[i]),
            inventory_item_id: inventory_item.id,
            variant_id: inventory_item.variant_id
          )
        end
      end

      private

      def cache_inventory(key, inventory_item, count_in_redis)
        return count_in_redis.to_i if count_in_redis.present?
        return inventory_item.quantity_available unless inventory_item.active?

        # Use atomic SET NX to prevent race condition where multiple concurrent reads
        # initialize cache with stale values. Only the first thread wins.
        SpreeCmCommissioner.redis_pool.with do |redis|
          redis.eval(set_nx_with_expiry_script, keys: [key], argv: [inventory_item.quantity_available, inventory_item.redis_expired_in])
        end

        inventory_item.quantity_available
      end

      def set_nx_with_expiry_script
        <<~LUA
          local key = KEYS[1]
          local value = tonumber(ARGV[1])
          local expiry = tonumber(ARGV[2])

          -- Using redis.call (not pcall) is intentional:
          -- - Script is simple and deterministic (EXISTS, SET are basic commands)
          -- - Exceptions indicate real Redis failures that should propagate to Ruby
          -- - Caller has fallback logic to use database values on error
          -- - Fail-fast semantics are preferred over silent error handling
          if redis.call('EXISTS', key) == 0 then
            redis.call('SET', key, value, 'EX', expiry)
            return 1
          else
            return 0
          end
        LUA
      end
    end
  end
end