Callback Free Concurrency:

Node vs. Elixir

Chris Geihsler

@seejee

 

  • 3,000,000 students
  • 7,000,000 problems / day
  • 1,000,000,000 problems / year

Live Math Tutoring

  • 1 2X Heroku Dyno
  • 1 Redis DB
  • $50 / month
  • ~70 concurrent sessions
  • 2,000,000 sessions / year

+

Joe Stanco, Packt Publishing

http://i.stack.imgur.com/BTm1H.png

Drawbacks

Callback Hell

Drawback:

Fault Tolerance

Drawback:

The safest way to respond to a thrown error is to shut down the process. 

Concurrency

Drawback:

Sample Application

Students

  • Wait in a queue
  • Private chat with a single teacher
  • Respond to every teacher message
  • Disconnect when teacher ends chat

Teachers

  • Chat with five students simultaneously
  • Pull students from the queue
  • End chat after receiving 50 messages

System

  • Know which users are connected
  • Know which chats are in-progress
  • Record the chat logs

Two* Implementations:

  • Node       0.10.33
  • Express   4.9.8
  • Faye         1.0.3
  • Socket.io 1.0
  • Erlang      17
  • Elixir        1.0.2
  • Phoenix   0.11

Model Layer

Teacher Roster

  • Which teachers are connected?
  • Which students are chatting with a teacher? 
function TeacherRoster() { this.teachers = {}; }

TeacherRoster.prototype.add = function(teacher, callback) {
  this.teachers[teacher.id] = teacher;
  callback(null, teacher);
};

TeacherRoster.prototype.find = function(id, callback) {
  var t = this.teachers[id];
  callback(null, t);
};

TeacherRoster.prototype.canAcceptMoreStudents = function(teacherId, callback) {
  this.find(teacherId, function(err, teacher) {
    var canAccept = teacher.students.length < 5;
    callback(null, canAccept);
  });
};

TeacherRoster.prototype.claimStudent = function(teacherId, studentId, callback) {
  this.find(teacherId, function(err, teacher) {
    teacher.students.push(studentId);
    callback(null, teacher);
  });
};

TeacherRoster.prototype.stats = function(callback) {
  var stats = { total: _.keys(@teachers).length };
  callback(null, stats);
};
defmodule ElixirChat.TeacherRoster do
  def new do
    HashDict.new
  end

  def add(roster, teacher) do
    teacher = Map.merge(teacher, %{student_ids: []})
    Dict.put(roster, teacher.id, teacher)
  end

  def find(roster, teacher_id) do
    roster[teacher_id]
  end

  def can_accept_more_students?(teacher) do
    length(teacher.student_ids) < 5
  end

  def claim_student(roster, teacher_id, student_id) do
    Dict.update!(roster, teacher_id, fn(t) ->
      %{t | student_ids: t.student_ids ++ [student_id]}
    end)
  end

  def stats(roster) do
    %{
      total: length(Dict.values(roster)),
    }
  end
end
iex(1)> alias ElixirChat.TeacherRoster, as: TeacherRoster
nil

iex(2)> roster = TeacherRoster.new
#HashDict<[]>

iex(3)> TeacherRoster.add(roster, %{id: 1})
#HashDict<[{1, %{id: 1, student_ids: []}}]>

iex(4)> roster
#HashDict<[]>

iex(5)> roster = TeacherRoster.add(roster, %{id: 1})
#HashDict<[{1, %{id: 1, student_ids: []}}]>

iex(6)> roster
#HashDict<[{1, %{id: 1, student_ids: []}}]>

iex(7)> roster = TeacherRoster.add(roster, %{id: 2})
#HashDict<[{2, %{id: 2, student_ids: []}}, {1, %{id: 1, student_ids: []}}]>

iex(8)> TeacherRoster.stats(roster)
%{total: 2}

iex(9)> TeacherRoster.new
         |> TeacherRoster.add(%{id: 10})
         |> TeacherRoster.add(%{id: 20})
         |> TeacherRoster.add(%{id: 30})
         |> TeacherRoster.stats
%{total: 3}
defmodule ElixirChat.TeacherRoster do
  def new do
  end

  def add(roster, teacher) do
  end

  def find(roster, teacher_id) do
  end

  def can_accept_more_students?(teacher) do
  end

  def claim_student(roster, teacher_id, student_id) do
  end

  def stats(roster) do
  end
end

Student Roster

  • Which students are connected?
  • Who is the next student in the queue? 
  • Which students are chatting?

Chat Log

  • Which chats are in progress?
  • What was said in each chat?

Chat Lifetime

  • Matches a teacher with the next student
  • Creates a new chat
  • Ends a chat
function ChatLifetime(teachers, students, chatLog) {
  this.teachers = teachers;
  this.students = students;
  this.chatLog  = chatLog;
}

ChatLifetime.prototype.createChatForNextStudent = function(teacherId, callback) {
  var _this = this;
  _this.teachers.canAcceptMoreStudents(teacherId, function(err, canAccept) {

    if(canAccept) {
      _this.students.next(function(err, student) {

        if(student) {
          _this.students.assignTo(student.id, teacherId, function(err) {
            _this.teachers.claimStudent(teacherId, student.id, function(err) {
              _this.chatLog.new(teacherId, student.id, function(err, chat) {
                callback(null, chat);
              });
            });
          });
        }
      });
    }
  });
};
defmodule ElixirChat.ChatLifetimeServer do
  use ExActor.GenServer, export: :chat_lifetime_server
  alias ElixirChat.ChatLogServer, as: Chats
  alias ElixirChat.TeacherRosterServer, as: Teachers
  alias ElixirChat.StudentRosterServer, as: Students

  defcall create_chat_for_next_student(teacher_id), state: _ do
    chat = nil

    if Teachers.can_accept_more_students?(teacher_id) do
      student_id = Students.next_student(teacher_id)

      if student_id do
        :ok  = Students.assign_student_to_teacher(student_id, teacher_id)
        :ok  = Teachers.claim_student(teacher_id, student_id)
        chat = Chats.new(teacher_id, student_id)
      end
    end

    reply(chat)
  end
end

WebSocket Layer

Presence Channel

  • Knows when users connect and disconnect
  • Knows when a teacher starts a new chat
  • Broadcasts student queue length
function PresenceChannel(faye) {
  // continued
  this.chatLifetime = new ChatLifetime(this.teachers, this.students, this.chatLog)
  this.chatChannel  = new ChatChannel(this.faye, this.chatLog, this.chatLifetime)
}

PresenceChannel.prototype.attach = function() {
  // continued
  this.faye.subscribe('/presence/claim_student', this.onClaimStudent.bind(this));
}

PresenceChannel.prototype.onClaimStudent = function(payload) {
  var _this = this;
  _this.chatLifetime.createChatForNextStudent(payload.teacherId, function(err, chat) {
    _this.chatChannel.attach(chat.id);
    _this.publishNewChat(chat);
  });
};

PresenceChannel.prototype.publishNewChat = function(chat) {
  var teacherChannel = "/presence/new_chat/teacher/" + chat.teacherId;
  var studentChannel = "/presence/new_chat/student/" + chat.studentId;

  this.faye.publish(teacherChannel, chat.teacherChannels);
  this.faye.publish(studentChannel, chat.studentChannels);
};
defmodule ElixirChat.PresenceChannel do
  use Phoenix.Channel
  alias ElixirChat.ChatLifetimeServer, as: Chats
  alias ElixirChat.TeacherRosterServer, as: Teachers
  alias ElixirChat.StudentRosterServer, as: Students

  def join(socket, topic, %{"userId" => id, "role" => "teacher"}) do
    Teachers.add(%{id: id})
    socket = assign(socket, :id, id)
    {:ok, socket}
  end

  def join(socket, topic, %{"userId" => id, "role" => "student"}) do
    Students.add(%{id: id})
    socket = assign(socket, :id, id)
    broadcast_status
    {:ok, socket}
  end

  def broadcast_status do
    data = %{
      teachers: Teachers.stats,
      students: Students.stats
    }

    broadcast "presence", "teachers", "user:status", data
  end
end
defmodule ElixirChat.PresenceChannel do
  def leave(socket, _message) do
    Students.remove(socket.assigns[:id])
    broadcast_status
    socket
  end

  def event(socket, "claim:student", %{"teacherId" => teacher_id}) do
    chat = Chats.create_chat_for_next_student(teacher_id)

    if chat do
      reply     socket,     "new:chat:#{chat.teacher_id}", chat
      broadcast "presence", "student:#{chat.student_id}", "new:chat", chat
    end

    socket
  end
end

Chat Channel

  • Private channel for a single chat
  • Relays messages between student and teacher
  • Terminates a chat

Student Client

  • Connect
  • Wait for a teacher to start a chat
  • Reply to every teacher message
  • Disconnect when teacher ends chat

Teacher Client

  • Connect
  • Start a chat with a student (up to 5)
  • Reply to every student message
  • End chat after 50 messages

Performance

  • 1000 students
  • 10 teachers
  • ~225,000 messages

Macbook Pro, 1 Core (January)

  • Node:  42s
  • Elixir:   54s

Macbook Pro, 1 Core (April)

  • Node:  41s
  • Elixir:   29s

Macbook Pro, 8 cores

  • Node:  41s
  • Elixir:   24s

Macbook Pro, 8 cores, split clients

  • Node:  41s
  • Elixir:   14s

Text

Scaling

Scaling

(in January)

80 hours later...

  • Rewriting server in Socket.io
  • Storing application state in Redis
  • Failing to store PubSub state in Redis
  • Using sticky Load Balancer Sessions
  • Manually routing chats to different processes
  • Fighting race conditions

After...

Scalable node, 8 cores, split clients

  • Node:  25s
  • Elixir:   14s

Learn both!

Thank you!

Chris Geihsler

@seejee

 

elixir-v-node (lightning)

By Chris Geihsler

elixir-v-node (lightning)

  • 1,542