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