Using Redis sets (unique lists) to track relationships between users and their friends, and making friend suggestions

In this blog post I'll share some code I was tinkering with that uses Redis sets (unique lists) to track relationships between users and their friends. My intention was to create a bunch of users, add a number of friends for each user, and then make a friend suggestion based on a user's friends.. friends.

I started by creating a Redis module that when included would add its methods to the class and its instances. file: lib/base.rb

require 'redis'

module FriendFinder
  module Base
    module Methods
      def redis
        @@redis ||= redis_connection
      end

      private

      def redis_connection
        Redis.new
      end
    end

    def self.included(klass)
      klass.extend(Methods)
      klass.send(:include, Methods)
    end
  end
end

Here are the guts of the User model module. file: lib/user.rb

module FriendFinder
  class User
    include Base
    include Friend

    def initialize(options = {})
      @data = options ||= {}
      fail 'Options must be a Hash' unless options.is_a?(Hash)

      # dynamically add a few methods
      # IE: makes user object instance var @data[:id] accessible as user.id
      [:id, :first_name, :last_name].each do |meth|
        unless self.class.method_defined?(meth)
          self.class.send(:define_method, meth) do
            @data[meth]
          end
        end
      end
    end

    # create a new user and store to redis hash
    # ex key: FriendFinder::User:1
    def create!
      options = default_options.merge(@data)
      redis.hmset(to_key, options.flatten)
      redis.sadd(self.class.id_key, id)
    end

    def default_options
      random_name
    end

    def random_name
      @@names ||= load_random_names
      { first_name: @@names[:first].sample, last_name: @@names[:last].sample }
    end

    # load random names from a file,
    # formatted: FIRST LAST\n
    def load_random_names
      ret = { first: [], last: [] }
      File.readlines('names.txt').each do |line|
        first_name, last_name = line.strip.split(/\s/)
        ret[:first] << first_name
        ret[:last] << last_name
      end
      ret
    end

    # define redis key for user
    # ex key: FriendFinder::User:1
    def to_key
      fail 'Id required' if id.nil?
      "#{self.class.name}:#{id}"
    end

    # load a user's data from redis hash
    def load!
      data = redis.hgetall(to_key)
      data = Hash[data.map { |(k, v)| [k.to_sym, v] }]
      @data.merge! data
      self
    end

    def puts_output
      puts "ID:\t\t#{id}"
      puts "first name:\t#{first_name}"
      puts "last name:\t#{last_name}"
      puts
    end

    #
    # Class methods
    #

    # define key to track set of redis user ids
    def self.id_key
      "#{name}:ID"
    end

    # select random user id
    def self.random_user_id
      redis.srandmember id_key
    end

    # create a bunch of users
    def self.create_users!(how_many = 1_000)
      fail 'How many must be an Integer' unless how_many.respond_to?(:times)
      how_many.times do |i|
        new(id: i + 1).create!
      end
    end

    # create a bunch of friends for each user
    def self.create_friends!(how_many_each = 100)
      fail 'How many each must be an Integer' unless how_many_each.respond_to?(:times)

      all_ids.each do |id|
        user = new(id: id).load!

        while user.friends_size < how_many_each
          user.friends_create!(random_user_id)
        end
      end
    end

    # get a list of all user ids
    def self.all_ids
      redis.smembers id_key
    end
  end
end

I created another module for Friend methods, which is included by the User module. file: lib/friend.rb

module FriendFinder
  module Friend
    def self.included(klass)
      klass.extend(ClassMethods)
      klass.send(:include, InstanceMethods)
    end

    module InstanceMethods
      def friends_key
        fail 'Id required' if id.nil?
        "#{self.class.name}:#{id}:Friends"
      end

      def friends_size
        redis.scard friends_key
      end

      def has_friend?(friend_id)
        redis.sismember friends_key, friend_id
      end

      def friends_create!(friend_id)
        return false if has_friend?(friend_id)
        redis.sadd friends_key, friend_id
      end

      def friend_ids
        redis.smembers friends_key
      end
    end

    module ClassMethods
      def create_friends!(how_many_each = 100)
        fail 'How many each must be an Integer' unless how_many_each.respond_to?(:times)

        all_ids.each do |id|
          user = new(id: id).load!

          while user.friends_size < how_many_each
            user.friends_create!(random_user_id)
          end
        end
      end

      def suggest_friend(user_id = nil)
        user_id ||= random_user_id
        fail 'User id required' if user_id.nil?

        user = new(id: user_id).load!

        # output
        puts 'Randomly selected user:'
        user.puts_output

        # get friends of friends
        friend_ids = user.friend_ids
        friends_of_friends = []
        friend_ids.each do |friend_id|
          friends_of_friends.push(*new(id: friend_id).load!.friend_ids)
        end

        fail 'No friends of friends found' if friends_of_friends.empty?

        # group/count
        friend_counts = friends_of_friends.inject(Hash.new(0)) { |h, i| h[i] += 1; h }

        # sort
        friend_counts = Hash[friend_counts.sort_by { |_k, v| -v }]

        # remove existing
        friend_counts.reject! { |k, _v| friend_ids.include?(k) }

        # get first friend suggestion
        suggested_friend_id, suggested_friend_count = friend_counts.first

        suggested_friend = new(id: suggested_friend_id).load!

        # output
        puts 'Suggested friend:'
        suggested_friend.puts_output
        puts "Number of friends with #{suggested_friend.first_name}: #{suggested_friend_count}"
      end
    end
  end
end

I created the next two modules to parse command line flags and interact with user objects.

# file: lib/main.rb
module FriendFinder
  module Main
    extend self

    def execute
      options = CommandLine.options

      case options[:action]

      when :create_users
        User.create_users!

      when :create_friends
        User.create_friends!

      when :suggest_friend
        User.suggest_friend

      else
        fail 'Action required'
      end
    end
  end
end
# file: lib/command_line.rb
require 'optparse'

module FriendFinder
  module CommandLine
    extend self

    def options
      @options ||= parse_options
    end

    def parse_options
      options = {}
      OptionParser.new do |opts|
        opts.banner = "Usage: #{$PROGRAM_NAME} [options]"

        opts.on('--create-users', 'Create users') do |_v|
          options[:action] = :create_users
        end

        opts.on('--create-friends', 'Create friends') do |_v|
          options[:action] = :create_friends
        end

        opts.on('--suggest-friend', 'Suggest friend') do |_v|
          options[:action] = :suggest_friend
        end
      end.parse!
      options
    end
  end
end

I tied all the modules together with a single file in the root of the project. file: main.rb

#!/usr/bin/env jruby

lib_dir = File.dirname(__FILE__) + '/lib'
$LOAD_PATH.unshift(lib_dir) unless $LOAD_PATH.include?(lib_dir)

require 'command_line'
require 'main'
require 'base'
require 'friend'
require 'user'

FriendFinder::Main.execute

Usage:

# monitor redis (separate terminal)
redis-cli monitor

# create users
./main.rb --create-users

# create friends
./main.rb --create-friends

# suggest a friend
./main.rb --suggest-friend

Sample output:

Randomly selected user:
ID:   597
first name: Pearlie
last name:  Christiansen

Suggested friend:
ID:   911
first name: Ilana
last name:  Kunkel

Number of friends with Ilana: 27

Source code on Github.