pushd
Version:
Blazing fast multi-protocol mobile push notification service
263 lines (240 loc) • 12.6 kB
text/coffeescript
crypto = require 'crypto'
async = require 'async'
Event = require('./event').Event
logger = require 'winston'
class Subscriber
getInstanceFromToken: (redis, proto, token, cb) ->
return until cb
throw new Error("Missing redis connection") if not redis?
throw new Error("Missing mandatory `proto' field") if not proto?
throw new Error("Missing mandatory `token' field") if not token?
redis.hget "tokenmap", "#{proto}:#{token}", (err, id) =>
if id?
# looks like this subscriber is already registered
redis.exists "subscriber:#{id}", (err, exists) =>
if exists
cb(new Subscriber(redis, id))
else
# duh!? the global list reference an unexisting object, fix this inconsistency and return no subscriber
redis.hdel "tokenmap", "#{proto}:#{token}", =>
cb(null)
else
cb(null) # No subscriber for this token
create: (redis, fields, cb, tentatives=0) ->
return until cb
throw new Error("Missing redis connection") if not redis?
throw new Error("Missing mandatory `proto' field") if not fields?.proto?
throw new Error("Missing mandatory `token' field") if not fields?.token?
if tentatives > 10
# exceeded the retry limit
throw new Error "Can't find free uniq id"
# verify if token is already registered
Subscriber::getInstanceFromToken redis, fields.proto, fields.token, (subscriber) =>
if subscriber?
# this subscriber is already registered
delete fields.token
delete fields.proto
subscriber.set fields, =>
cb(subscriber, created=false, tentatives)
else
# register the subscriber using a randomly generated id
crypto.randomBytes 8, (ex, buf) =>
# generate a base64url random uniq id
id = buf.toString('base64').replace(/\=+$/, '').replace(/\//g, '_').replace(/\+/g, '-')
redis.watch "subscriber:#{id}", =>
redis.exists "subscriber:#{id}", (err, exists) =>
if exists
# already exists, rollback and retry with another id
redis.discard =>
return Subscriber::create(redis, fields, cb, tentatives + 1)
else
fields.created = fields.updated = Math.round(new Date().getTime() / 1000)
redis.multi()
# register subscriber token to db id
.hsetnx("tokenmap", "#{fields.proto}:#{fields.token}", id)
# register subscriber to global list
.zadd("subscribers", 0, id)
# save fields
.hmset("subscriber:#{id}", fields)
.exec (err, results) =>
if results is null
# Transction discarded due to a parallel creation of the watched subscriber key
# Try again in order to get the peer created subscriber
return Subscriber::create(redis, fields, cb, tentatives + 1)
if not results[0]
# Unlikly race condition: another client registered the same token at the same time
# Rollback and retry the registration so we can return the peer subscriber id
redis.del "subscriber:#{id}", =>
return Subscriber::create(redis, fields, cb, tentatives + 1)
else
# done
cb(new Subscriber(redis, id), created=true, tentatives)
constructor: (@redis, @id) ->
@info = null
@key = "subscriber:#{@id}"
delete: (cb) ->
@redis.multi()
# get subscriber's token
.hmget(@key, 'proto', 'token')
# gather subscriptions
.zrange("subscriber:#{@id}:evts", 0, -1)
.exec (err, results) =>
[proto, token] = results[0]
events = results[1]
multi = @redis.multi()
# remove from subscriber token to id map
.hdel("tokenmap", "#{proto}:#{token}")
# remove from global subscriber list
.zrem("subscribers", @id)
# remove subscriber info hash
.del(@key)
# remove subscription list
.del("#{@key}:evts")
# unsubscribe subscriber from all subscribed events
for eventName in events
multi.zrem("event:#{eventName}:subs", @id)
# count subscribers after zrem
multi.zcard("event:#{eventName}:subs")
multi.exec (err, results) =>
@info = null # flush cache
# check if some events have been rendered empty
emptyEvents = []
for eventName, i in events when results[4 + i + (i * 1) + 1] is 0
emptyEvents.push new Event(@redis, eventName)
async.forEach emptyEvents, ((evt, done) => evt.delete(done)), =>
cb(results[1] is 1) if cb # true if deleted, false if did exist
get: (cb) ->
return until cb
# returned cached value or perform query
if @info?
cb(@info)
else
@redis.hgetall @key, (err, @info) =>
if info?.updated? # subscriber exists
# transform numeric value to number type
for own key, value of info
num = parseInt(value)
@info[key] = if num + '' is value then num else value
cb(@info)
else
cb(@info = null) # null if subscriber doesn't exist + flush cache
set: (fieldsAndValues, cb) ->
# TODO handle token update needed for Android
throw new Error("Can't modify `token` field") if fieldsAndValues.token?
throw new Error("Can't modify `proto` field") if fieldsAndValues.proto?
fieldsAndValues.updated = Math.round(new Date().getTime() / 1000)
@redis.multi()
# check subscriber existance
.zscore("subscribers", @id)
# edit fields
.hmset(@key, fieldsAndValues)
.exec (err, results) =>
@info = null # flush cache
if results && results[0]? # subscriber exists?
cb(true) if cb
else
# remove edited fields
@redis.del @key, =>
cb(null) if cb # null if subscriber doesn't exist
incr: (field, cb) ->
@redis.multi()
# check subscriber existance
.zscore("subscribers", @id)
# increment field
.hincrby(@key, field, 1)
.exec (err, results) =>
if results[0]? # subscriber exists?
@info[field] = results[1] if @info? # update cache field
cb(results[1]) if cb
else
@info = null # flush cache
# remove edited field
@redis.del @key, =>
cb(null) if cb # null if subscriber doesn't exist
getSubscriptions: (cb) ->
return unless cb
@redis.multi()
# check subscriber existance
.zscore("subscribers", @id)
# gather all subscriptions
.zrange("#{@key}:evts", 0, -1, 'WITHSCORES')
.exec (err, results) =>
if results[0]? # subscriber exists?
subscriptions = []
eventsWithOptions = results[1]
if eventsWithOptions?
for eventName, i in eventsWithOptions by 2
subscriptions.push
event: new Event(@redis, eventName)
options: parseInt(eventsWithOptions[i + 1], 10)
cb(subscriptions)
else
cb(null) # null if subscriber doesn't exist
getSubscription: (event, cb) ->
return unless cb
@redis.multi()
# check subscriber existance
.zscore("subscribers", @id)
# gather all subscriptions
.zscore("#{@key}:evts", event.name)
.exec (err, results) =>
if results[0]? and results[1]? # subscriber and subscription exists?
cb
event: event
options: parseInt(results[1], 10)
else
cb(null) # null if subscriber doesn't exist
addSubscription: (event, options, cb) ->
@redis.multi()
# check subscriber existance
.zscore("subscribers", @id)
# add event to subscriber's subscriptions list
.zadd("#{@key}:evts", options, event.name)
# add subscriber to event's subscribers list
.zadd("#{event.key}:subs", options, @id)
# set the event created field if not already there (event is lazily created on first subscription)
.hsetnx(event.key, "created", Math.round(new Date().getTime() / 1000))
# lazily add event to the global event list
.sadd("events", event.name)
.exec (err, results) =>
if results[0]? # subscriber exists?
logger.verbose "Registered subscriber #{@id} to event #{event.name}"
cb(results[1] is 1) if cb
else
# Tried to add a sub on an unexisting subscriber, remove just added sub
# This is an exception so we don't first check subscriber existance before to add sub,
# but we manually rollback the subscription in case of error
@redis.multi()
# remove the wrongly created subs subscriber relation
.del("#{@key}:evts", event.name)
# remove the subscriber from the event's subscribers list
.zrem("#{event.key}:subs", @id)
# check if the subscriber list still exist after previous zrem
.zcard("#{event.key}:subs")
.exec (err, results) =>
if results[2] is 0
# The event subscriber list is now empty, clean it
event.delete() # TOFIX possible race condition
cb(null) if cb # null if subscriber doesn't exist
removeSubscription: (event, cb) ->
@redis.multi()
# check subscriber existance
.zscore("subscribers", @id)
# remove event from subscriber's subscriptions list
.zrem("#{@key}:evts", event.name)
# remove the subscriber from the event's subscribers list
.zrem("#{event.key}:subs", @id)
# check if the subscriber list still exist after previous zrem
.zcard("#{event.key}:subs")
.exec (err, results) =>
if results[3] is 0
# The event subscriber list is now empty, clean it
event.delete() # TOFIX possible race condition
if results[0]? # subscriber exists?
wasRemoved = results[1] is 1 # true if removed, false if wasn't subscribed
if wasRemoved
logger.verbose "Subscriber #{@id} unregistered from event #{event.name}"
cb(wasRemoved) if cb
else
cb(null) if cb # null if subscriber doesn't exist
exports.Subscriber = Subscriber