├── .npmignore ├── index.js ├── test ├── mocha.opts ├── ApiTests.js ├── MultiNodes.js ├── ErrorTests.js └── SingleNode.js ├── .travis.yml ├── repro └── issue_003 │ ├── package.json │ └── test.js ├── lib ├── lua │ ├── cancel.lua │ ├── changeDelay.lua │ └── update.lua └── dtimer.js ├── .gitignore ├── package.json └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | test 2 | repro 3 | coverage 4 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.DTimer = require('./lib/dtimer').DTimer; 4 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --ui bdd 2 | --reporter spec 3 | --timeout 20000 4 | --recursive 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "4.2" 4 | - "0.10" 5 | services: 6 | - redis-server 7 | script: 8 | - npm test 9 | after_success: 10 | - cat ./coverage/lcov.info |./node_modules/coveralls/bin/coveralls.js 11 | -------------------------------------------------------------------------------- /repro/issue_003/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "issue_003", 3 | "version": "0.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "DEBUG=issue_003 node test" 8 | }, 9 | "author": "", 10 | "license": "MIT", 11 | "dependencies": { 12 | "redis": "^0.12.1", 13 | "debug": "^2.0.0", 14 | "date-utils": "^1.2.16" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /lib/lua/cancel.lua: -------------------------------------------------------------------------------- 1 | -- Input parameters 2 | -- KEYS[1] redis key for notified event ID sorted-set 3 | -- KEYS[2] redis key for event hash 4 | -- ARGV[1] event ID to be cancelled 5 | -- Output parameters 6 | -- {number} 0: nothing cancelled. 1: cancelled. 7 | local evId = ARGV[1] 8 | local res 9 | 10 | -- Remove from et table 11 | res = redis.call("ZREM", KEYS[1], evId) 12 | if res == 1 then 13 | redis.call("HDEL", KEYS[2], evId) 14 | end 15 | 16 | return res 17 | -------------------------------------------------------------------------------- /lib/lua/changeDelay.lua: -------------------------------------------------------------------------------- 1 | -- Input parameters 2 | -- KEYS[1] redis key for event ID sorted-set 3 | -- ARGV[1] event ID to be cancelled 4 | -- ARGV[2] new expration time 5 | -- Output parameters 6 | -- {number} 0: event not found. 1: changed successfully. 7 | local evId = ARGV[1] 8 | local expireAt = tonumber(ARGV[2]) 9 | local res 10 | 11 | -- Check if the event exists in ei table 12 | res = redis.call("ZSCORE", KEYS[1], evId) 13 | if res ~= nil then 14 | redis.call("ZADD", KEYS[1], expireAt, evId) 15 | return 1 16 | end 17 | 18 | return 0 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Dependency directory 20 | # Deployed apps should consider commenting this line out: 21 | # see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git 22 | node_modules 23 | 24 | # Other temporary files 25 | *.swp 26 | *.swo 27 | -------------------------------------------------------------------------------- /repro/issue_003/test.js: -------------------------------------------------------------------------------- 1 | var DTimer = require('../..').DTimer, 2 | redis = require('redis'), 3 | pub = redis.createClient(), 4 | sub = redis.createClient(), 5 | dt = new DTimer('ch1', pub, sub), 6 | postTime; 7 | var debug = require('debug')('issue_003'); 8 | 9 | require('date-utils'); 10 | 11 | postTime = new Date().addSeconds(2); 12 | 13 | dt.on('event', function (ev) { 14 | debug('EV', ev); 15 | dt.leave(function(){ 16 | debug('leaving and quiting.'); 17 | pub.quit(); 18 | sub.quit(); 19 | }); 20 | }) 21 | dt.on('error', function (err) { 22 | // handle error 23 | }) 24 | dt.join(function (err) { 25 | if (err) { 26 | // join failed 27 | return; 28 | } 29 | // join successfully 30 | }) 31 | 32 | dt.post({msg:'howdy'}, postTime.getTime()-new Date().getTime(), function (err, evId) { 33 | if (err) { 34 | // failed to post event 35 | return; 36 | } 37 | 38 | debug('posted #id',evId,' time:',postTime); 39 | // posted event successfully 40 | // If you need to cancel this event, then do: 41 | //dt.cancel(evId, function (err) {...}); 42 | }); 43 | 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dtimer", 3 | "version": "0.3.3", 4 | "description": "Distributed timer backed by Redis.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "DEBUG=dtimer istanbul cover _mocha", 8 | "posttest": "eslint lib test; istanbul check-coverage", 9 | "lint": "eslint lib test" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git://github.com/enobufs/dtimer.git" 14 | }, 15 | "dependencies": { 16 | "bluebird": "^3.1.1", 17 | "debug": "~2.6.9", 18 | "lured": "~0.0.2", 19 | "redis": "~0.12.1", 20 | "underscore": "~1.6.0", 21 | "uuid": "^2.0.1" 22 | }, 23 | "devDependencies": { 24 | "async": "~0.9.0", 25 | "coveralls": "~2.13.3", 26 | "eslint": "^1.10.3", 27 | "istanbul": "~0.3.0", 28 | "mocha": "~1.21.0", 29 | "sinon": "~1.10.3" 30 | }, 31 | "keywords": [ 32 | "timer", 33 | "timed", 34 | "scheduler", 35 | "event", 36 | "cluster", 37 | "redis" 38 | ], 39 | "author": "enobufs", 40 | "license": "MIT", 41 | "bugs": { 42 | "url": "https://github.com/enobufs/dtimer/issues" 43 | }, 44 | "homepage": "https://github.com/enobufs/dtimer", 45 | "eslintConfig": { 46 | "rules": { 47 | "callback-return": [ 48 | 2, 49 | [ 50 | "callback", 51 | "cb", 52 | "next", 53 | "done" 54 | ] 55 | ], 56 | "camelcase": [ 57 | 2, 58 | { 59 | "properties": "never" 60 | } 61 | ], 62 | "comma-dangle": 0, 63 | "comma-spacing": [ 64 | 2, 65 | { 66 | "before": false, 67 | "after": true 68 | } 69 | ], 70 | "indent": [ 71 | 2, 72 | 4, 73 | { 74 | "SwitchCase": 1 75 | } 76 | ], 77 | "linebreak-style": [ 78 | 2, 79 | "unix" 80 | ], 81 | "max-len": [ 82 | 1, 83 | 120, 84 | 4, 85 | { 86 | "ignoreComments": true, 87 | "ignoreUrls": true 88 | } 89 | ], 90 | "no-console": 0, 91 | "no-extra-boolean-cast": [ 92 | 0 93 | ], 94 | "no-new": 2, 95 | "no-spaced-func": [ 96 | 2 97 | ], 98 | "no-trailing-spaces": [ 99 | 2 100 | ], 101 | "no-unused-vars": [ 102 | 2, 103 | { 104 | "args": "all" 105 | } 106 | ], 107 | "no-use-before-define": [ 108 | 2, 109 | "nofunc" 110 | ], 111 | "semi": [ 112 | 2, 113 | "always" 114 | ], 115 | "space-after-keywords": [ 116 | 2, 117 | "always" 118 | ], 119 | "space-before-function-paren": [ 120 | 2, 121 | { 122 | "anonymous": "always", 123 | "named": "never" 124 | } 125 | ], 126 | "space-return-throw-case": 2 127 | }, 128 | "env": { 129 | "node": true, 130 | "mocha": true 131 | }, 132 | "extends": "eslint:recommended" 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /lib/lua/update.lua: -------------------------------------------------------------------------------- 1 | -- Input parameters 2 | -- KEYS[1] redis key for global hash 3 | -- KEYS[2] redis key for channel list 4 | -- KEYS[3] redis key for event ID sorted-set 5 | -- KEYS[4] redis key for event hash 6 | -- KEYS[5] redis key for notified event ID sorted-set 7 | -- ARGV[1] channel ID 8 | -- ARGV[2] current time (equivalent to TIME, in msec) 9 | -- ARGV[3] max number of events 10 | -- ARGV[4] confirmation timeout (sec) 11 | -- Output parameters 12 | -- {array} Events 13 | -- {number} Next (suggested) interval in milliseconds. 14 | redis.call("HSETNX", KEYS[1], "gracePeriod", 20) 15 | redis.call("HSETNX", KEYS[1], "baseInterval", 1000) 16 | local events = {} 17 | local chId = ARGV[1] 18 | local now = tonumber(ARGV[2]) 19 | local numMaxEvents = tonumber(ARGV[3]) 20 | local confTimeout = tonumber(ARGV[4]) * 1000 21 | local gracePeriod = tonumber(redis.call("HGET", KEYS[1], "gracePeriod")) 22 | local baseInterval = tonumber(redis.call("HGET", KEYS[1], "baseInterval")) 23 | local numChs = tonumber(redis.call("LLEN", KEYS[2])) 24 | local defaultInterval = numChs * baseInterval 25 | local numEvents 26 | local ev 27 | local evIds 28 | local evId 29 | local evStr 30 | 31 | redis.log(redis.LOG_DEBUG,"numChs=" .. numChs) 32 | redis.log(redis.LOG_DEBUG,"confTimeout=" .. confTimeout) 33 | 34 | -- Put confirmation timed out events back into ei/ed tables. 35 | numEvents = redis.call("ZCARD", KEYS[5]) 36 | if numEvents > 0 then 37 | evIds = redis.call("ZRANGEBYSCORE", KEYS[5], 0, now) 38 | if #evIds > 0 then 39 | redis.call("ZREMRANGEBYRANK", KEYS[5], 0, #evIds - 1) 40 | for i=1, #evIds do 41 | evId = evIds[i] 42 | evStr = redis.call("HGET", KEYS[4], evId) 43 | if evStr ~= nil then 44 | if pcall(function () ev = cjson.decode(evStr) end) then 45 | if ev._numRetries < ev.maxRetries then 46 | ev._numRetries = ev._numRetries + 1 47 | redis.call("ZADD", KEYS[3], now, evId) 48 | redis.call("HSET", KEYS[4], evId, cjson.encode(ev)) 49 | else 50 | redis.call("HDEL", KEYS[4], evId) 51 | end 52 | else 53 | redis.call("HDEL", KEYS[4], evId) 54 | end 55 | end 56 | end 57 | end 58 | end 59 | 60 | if numMaxEvents > 0 then 61 | evIds = redis.call("ZRANGEBYSCORE", KEYS[3], 0, now, "LIMIT", 0, numMaxEvents) 62 | if #evIds > 0 then 63 | redis.call("ZREMRANGEBYRANK", KEYS[3], 0, #evIds - 1) 64 | for i=1, #evIds do 65 | evId = evIds[i] 66 | evStr = redis.call("HGET", KEYS[4], evId) 67 | if evStr ~= nil then 68 | if pcall(function () ev = cjson.decode(evStr) end) then 69 | table.insert(events, evStr) 70 | if ev.maxRetries > 0 then 71 | -- Ensure maxRetries and _numRetries properties 72 | if ev.maxRetries == nil or ev._numRetries == nil then 73 | if ev.maxRetries == nil then 74 | ev.maxRetries = 0 75 | end 76 | if ev._numRetries == nil then 77 | ev._numRetries = 0 78 | end 79 | redis.call("HSET", KEYS[4], evId, cjson.encode(ev)) 80 | end 81 | 82 | -- Put it into et table while waiting for confirmation 83 | redis.call("ZADD", KEYS[5], now+confTimeout, evId) 84 | else 85 | redis.call("HDEL", KEYS[4], evId) 86 | end 87 | else 88 | redis.call("HDEL", KEYS[4], evId) 89 | end 90 | end 91 | end 92 | 93 | if redis.call("LINDEX", KEYS[2], numChs-1) == chId then 94 | if numChs > 1 then 95 | redis.call("RPOPLPUSH", KEYS[2], KEYS[2]) 96 | end 97 | redis.call("HDEL", KEYS[1], "expiresAt") 98 | end 99 | end 100 | end 101 | 102 | numEvents = redis.call("ZCARD", KEYS[3]) 103 | if numEvents == 0 then 104 | redis.call("HDEL", KEYS[1], "expiresAt") 105 | else 106 | local notify = true 107 | local nexp = tonumber(redis.call("ZRANGE", KEYS[3], 0, 0, "WITHSCORES")[2]) 108 | if redis.call("HEXISTS", KEYS[1], "expiresAt") == 1 then 109 | local cexp = tonumber(redis.call("HGET", KEYS[1], "expiresAt")) 110 | if cexp > nexp and cexp - nexp > gracePeriod then 111 | redis.call("HDEL", KEYS[1], "expiresAt") 112 | else 113 | notify = false 114 | end 115 | end 116 | if numChs > 0 and notify then 117 | local interval = 0 118 | if nexp > now then 119 | interval = nexp - now 120 | end 121 | if interval + gracePeriod < defaultInterval then 122 | while numChs > 0 do 123 | chId = redis.call("LINDEX", KEYS[2], numChs-1) 124 | local ret = redis.call("PUBLISH", chId, '{"interval":' .. interval .. '}') 125 | if ret > 0 then 126 | redis.call("HSET", KEYS[1], "expiresAt", nexp) 127 | break 128 | end 129 | redis.call("RPOP", KEYS[2]) 130 | numChs = tonumber(redis.call("LLEN", KEYS[2])) 131 | end 132 | end 133 | end 134 | end 135 | 136 | return {events, defaultInterval} 137 | -------------------------------------------------------------------------------- /test/ApiTests.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var DTimer = require('..').DTimer; 4 | var assert = require('assert'); 5 | var sinon = require('sinon'); 6 | var Promise = require('bluebird'); 7 | var redis = Promise.promisifyAll(require("redis")); 8 | 9 | describe('ApiTests', function () { 10 | var sandbox = sinon.sandbox.create(); 11 | var clock; 12 | 13 | before(function () { 14 | sandbox = sinon.sandbox.create(); 15 | }); 16 | 17 | beforeEach(function () { 18 | clock = null; 19 | }); 20 | 21 | afterEach(function () { 22 | sandbox.restore(); 23 | if (clock) { 24 | clock.restore(); 25 | } 26 | }); 27 | 28 | it('The pub should not be null', function () { 29 | assert.throws(function () { 30 | var dt = new DTimer('me', null); 31 | void(dt); 32 | }, 33 | function (err) { 34 | return (err instanceof Error); 35 | }, 36 | "unexpected error" 37 | ); 38 | }); 39 | 40 | it('When sub is present, id must be non-empty string', function () { 41 | var pub = redis.createClient(); 42 | var sub = redis.createClient(); 43 | function shouldThrow(id) { 44 | assert.throws(function () { 45 | var dt = new DTimer(id, pub, sub); 46 | void(dt); 47 | }, 48 | function (err) { 49 | return (err instanceof Error); 50 | }, 51 | "unexpected error" 52 | ); 53 | } 54 | // Should not be undefined 55 | shouldThrow(void(0)); 56 | // Should not be null 57 | shouldThrow(null); 58 | // Should not be a number 59 | shouldThrow(1); 60 | // Should not be empty string 61 | shouldThrow(''); 62 | }); 63 | 64 | it('maxEvents - defaults to 8', function () { 65 | var pub = redis.createClient(); 66 | var dt = new DTimer('me', pub, null); 67 | assert.strictEqual(dt.maxEvents, 8); 68 | }); 69 | it('maxEvents - use option to change default', function () { 70 | var pub = redis.createClient(); 71 | var dt = new DTimer('me', pub, null, { maxEvents:4 }); 72 | assert.strictEqual(dt.maxEvents, 4); 73 | }); 74 | it('maxEvents - reset to default by setting with 0', function () { 75 | var pub = redis.createClient(); 76 | var dt = new DTimer('me', pub, null); 77 | assert.strictEqual(dt.maxEvents, 8); 78 | dt.maxEvents = 3; 79 | assert.strictEqual(dt.maxEvents, 3); 80 | dt.maxEvents = 0; 81 | assert.strictEqual(dt.maxEvents, 8); 82 | dt.maxEvents = 12; 83 | assert.strictEqual(dt.maxEvents, 12); 84 | dt.maxEvents = -1; 85 | assert.strictEqual(dt.maxEvents, 8); 86 | }); 87 | 88 | it('emits error when lured.load() fails', function (done) { 89 | sandbox.stub(require('lured'), 'create', function () { 90 | return { 91 | on: function () {}, 92 | load: function (cb) { 93 | process.nextTick(function () { 94 | cb(new Error('fake exception')); 95 | }); 96 | } 97 | }; 98 | }); 99 | var pub = redis.createClient(); 100 | var dt = new DTimer('me', pub, null); 101 | dt.on('error', function (err) { 102 | assert(err instanceof Error); 103 | done(); 104 | }); 105 | }); 106 | 107 | it('Detect malformed message on subscribe', function (done) { 108 | sandbox.stub(require('lured'), 'create', function () { 109 | return { 110 | on: function () {}, 111 | load: function (cb) { process.nextTick(cb); } 112 | }; 113 | }); 114 | var pub = redis.createClient(); 115 | var dt = new DTimer('me', pub, null); 116 | dt.on('error', function (err) { 117 | assert(err instanceof Error); 118 | done(); 119 | }); 120 | dt._onSubMessage('me', "{bad"); 121 | }); 122 | 123 | it('Ignore invalid interval in pubsub message', function () { 124 | sandbox.stub(require('lured'), 'create', function () { 125 | return { 126 | on: function () {}, 127 | load: function (cb) { process.nextTick(cb); } 128 | }; 129 | }); 130 | var pub = redis.createClient(); 131 | var dt = new DTimer('me', pub, null); 132 | dt.on('error', function (err) { 133 | assert.ifError(err); 134 | }); 135 | dt._onSubMessage('me', '{"interval":true}'); 136 | assert.ok(!dt._timer); 137 | }); 138 | 139 | it('join() should fail without sub', function (done) { 140 | sandbox.stub(require('lured'), 'create', function () { 141 | return { 142 | on: function () {}, 143 | load: function (cb) { process.nextTick(cb); } 144 | }; 145 | }); 146 | var pub = redis.createClient(); 147 | var dt = new DTimer('me', pub, null); 148 | dt.join(function (err) { 149 | assert.ok(err); 150 | done(); 151 | }); 152 | }); 153 | 154 | it('leave() should fail without sub', function (done) { 155 | sandbox.stub(require('lured'), 'create', function () { 156 | return { 157 | on: function () {}, 158 | load: function (cb) { process.nextTick(cb); } 159 | }; 160 | }); 161 | var pub = redis.createClient(); 162 | var dt = new DTimer('me', pub, null); 163 | dt.leave(function (err) { 164 | assert.ok(err); 165 | done(); 166 | }); 167 | }); 168 | 169 | it('Attempts to post non-object event should throw', function () { 170 | sandbox.stub(require('lured'), 'create', function () { 171 | return { 172 | on: function () {}, 173 | load: function (cb) { process.nextTick(cb); }, 174 | state: 3 175 | }; 176 | }); 177 | var pub = redis.createClient(); 178 | var dt = new DTimer('me', pub, null); 179 | assert.throws(function () { 180 | dt.post('please throw', 1000, function () {}); 181 | }, 182 | function (err) { 183 | return (err instanceof Error); 184 | }, 185 | "unexpected error" 186 | ); 187 | }); 188 | 189 | it('Attempts to post with invalid event ID should throw', function () { 190 | sandbox.stub(require('lured'), 'create', function () { 191 | return { 192 | on: function () {}, 193 | load: function (cb) { process.nextTick(cb); }, 194 | state: 3 195 | }; 196 | }); 197 | var pub = redis.createClient(); 198 | var dt = new DTimer('me', pub, null); 199 | assert.throws(function () { 200 | dt.post({ id: 666 /*bad*/ }, 1000, function () {}); 201 | }, Error); 202 | }); 203 | 204 | it('Attempts to post with invalid maxRetries should throw', function () { 205 | sandbox.stub(require('lured'), 'create', function () { 206 | return { 207 | on: function () {}, 208 | load: function (cb) { process.nextTick(cb); }, 209 | state: 3 210 | }; 211 | }); 212 | var pub = redis.createClient(); 213 | var dt = new DTimer('me', pub, null); 214 | assert.throws(function () { 215 | dt.post({ id: 'should throw', maxRetries: true /*bad*/ }, 1000, function () {}); 216 | }, Error); 217 | }); 218 | 219 | it('Attempts to post with delay of type string should throw', function () { 220 | sandbox.stub(require('lured'), 'create', function () { 221 | return { 222 | on: function () {}, 223 | load: function (cb) { process.nextTick(cb); }, 224 | state: 3 225 | }; 226 | }); 227 | var pub = redis.createClient(); 228 | var dt = new DTimer('me', pub, null); 229 | assert.throws(function () { 230 | dt.post({ id: 'should throw', maxRetries: 5 }, '1000' /*bad*/, function () {}); 231 | }, Error); 232 | }); 233 | 234 | it('Attempts to changeDelay with delay of type string should throw', function () { 235 | sandbox.stub(require('lured'), 'create', function () { 236 | return { 237 | on: function () {}, 238 | load: function (cb) { process.nextTick(cb); }, 239 | state: 3 240 | }; 241 | }); 242 | var pub = redis.createClient(); 243 | var dt = new DTimer('me', pub, null); 244 | assert.throws(function () { 245 | dt.changeDelay({ id: 'should throw', maxRetries: 5 }, '1000' /*bad*/, function () {}); 246 | }, Error); 247 | }); 248 | }); 249 | -------------------------------------------------------------------------------- /test/MultiNodes.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var DTimer = require('..').DTimer; 4 | var async = require('async'); 5 | var assert = require('assert'); 6 | var Promise = require('bluebird'); 7 | var redis = Promise.promisifyAll(require("redis")); 8 | 9 | describe('Multiple nodes', function () { 10 | var numNodes = 8; 11 | var nodes; 12 | 13 | before(function () { 14 | }); 15 | 16 | beforeEach(function (done) { 17 | nodes = []; 18 | function prepareNode(id, cb) { 19 | var node = { 20 | id: id, 21 | numRcvd: 0 22 | }; 23 | async.series([ 24 | function (next) { 25 | node.pub = redis.createClient(); 26 | node.pub.once('ready', next); 27 | }, 28 | function (next) { 29 | node.sub = redis.createClient(); 30 | node.sub.once('ready', next); 31 | }, 32 | function (next) { 33 | node.pub.select(9, next); 34 | } 35 | ], function (err) { 36 | if (err) { return void(cb(err)); } 37 | node.dt = new DTimer('ch' + id, node.pub, node.sub); 38 | setTimeout(function () { cb(null, node); }, 100); 39 | }); 40 | } 41 | 42 | async.whilst( 43 | function () { return (nodes.length < numNodes); }, 44 | function (next) { 45 | prepareNode(nodes.length, function (err, node) { 46 | if (err) { return void(next(err)); } 47 | nodes.push(node); 48 | next(); 49 | }); 50 | }, 51 | function (err) { 52 | if (err) { 53 | return void(done(err)); 54 | } 55 | nodes[0].pub.flushdb(done); 56 | } 57 | ); 58 | }); 59 | 60 | afterEach(function () { 61 | nodes.forEach(function (node) { 62 | node.dt.removeAllListeners(); 63 | node.dt = null; 64 | node.pub.removeAllListeners(); 65 | node.pub.end(); 66 | node.pub = null; 67 | node.sub.removeAllListeners(); 68 | node.sub.end(); 69 | node.sub = null; 70 | }); 71 | nodes = []; 72 | }); 73 | 74 | it('MaxEvents 1 - each node receives 1 event', function (done) { 75 | var evts = [ 76 | { msg: { msg: 'msg0' }, delay: 60 }, 77 | { msg: { msg: 'msg1' }, delay: 60 }, 78 | { msg: { msg: 'msg2' }, delay: 60 }, 79 | { msg: { msg: 'msg3' }, delay: 60 }, 80 | { msg: { msg: 'msg4' }, delay: 60 }, 81 | { msg: { msg: 'msg5' }, delay: 60 }, 82 | { msg: { msg: 'msg6' }, delay: 60 }, 83 | { msg: { msg: 'msg7' }, delay: 60 } 84 | ]; 85 | var numRcvd = 0; 86 | // Set up each node to grab 1 event at a time. 87 | nodes.forEach(function (node) { 88 | node.dt.maxEvents = 1; 89 | }); 90 | async.series([ 91 | function (next) { 92 | var numJoined = 0; 93 | nodes.forEach(function (node) { 94 | node.dt.join(function (err) { 95 | if (err) { return void(next(err)); } 96 | numJoined++; 97 | if (numJoined === nodes.length) { 98 | return next(); 99 | } 100 | }); 101 | }); 102 | }, 103 | function (next) { 104 | var since = Date.now(); 105 | evts.forEach(function (evt) { 106 | nodes[0].dt.post(evt.msg, evt.delay, function (err, evId) { 107 | assert.ifError(err); 108 | evt.id = evId; 109 | evt.postDelay = Date.now() - since; 110 | evt.posted = true; 111 | }); 112 | }); 113 | nodes.forEach(function (node) { 114 | node.dt.on('event', function (ev) { 115 | node.numRcvd++; 116 | var elapsed = Date.now() - since; 117 | evts.forEach(function (evt) { 118 | if (evt.msg.msg === ev.msg) { 119 | numRcvd++; 120 | evt.elapsed = elapsed; 121 | evt.rcvd = ev; 122 | evt.rcvdBy = node.id; 123 | evt.order = numRcvd; 124 | } 125 | }); 126 | }); 127 | }); 128 | setTimeout(next, 100); 129 | }, 130 | function (next) { 131 | nodes[0].pub.llen('dt:ch', function (err, reply) { 132 | assert.ifError(err); 133 | assert.equal(reply, 8); 134 | next(); 135 | }); 136 | }, 137 | function (next) { 138 | var numLeft = 0; 139 | nodes.forEach(function (node) { 140 | node.dt.leave(function () { 141 | numLeft ++; 142 | if (numLeft === nodes.length) { 143 | return next(); 144 | } 145 | }); 146 | }); 147 | } 148 | ], function (err, results) { 149 | void(results); 150 | assert.ifError(err); 151 | evts.forEach(function (evt) { 152 | assert.ok(evt.posted); 153 | assert.deepEqual(evt.msg.msg, evt.rcvd.msg); 154 | assert(evt.elapsed < evt.delay + 200); 155 | assert(evt.elapsed > evt.delay); 156 | }); 157 | assert.equal(numRcvd, evts.length); 158 | nodes.forEach(function (node) { 159 | assert.equal(node.numRcvd, 1); 160 | }); 161 | done(); 162 | }); 163 | }); 164 | 165 | it('MaxEvents 2 - each node receives 0 or 2 events', function (done) { 166 | var evts = [ 167 | { msg: { msg: 'msg0' }, delay: 60 }, 168 | { msg: { msg: 'msg1' }, delay: 60 }, 169 | { msg: { msg: 'msg2' }, delay: 60 }, 170 | { msg: { msg: 'msg3' }, delay: 60 }, 171 | { msg: { msg: 'msg4' }, delay: 60 }, 172 | { msg: { msg: 'msg5' }, delay: 60 }, 173 | { msg: { msg: 'msg6' }, delay: 60 }, 174 | { msg: { msg: 'msg7' }, delay: 60 } 175 | ]; 176 | var numRcvd = 0; 177 | // Set up each node to grab 1 event at a time. 178 | nodes.forEach(function (node) { 179 | node.dt.maxEvents = 2; 180 | }); 181 | async.series([ 182 | function (next) { 183 | var numJoined = 0; 184 | nodes.forEach(function (node) { 185 | node.dt.join(function (err) { 186 | if (err) { return void(next(err)); } 187 | numJoined++; 188 | if (numJoined === nodes.length) { 189 | return next(); 190 | } 191 | }); 192 | }); 193 | }, 194 | function (next) { 195 | var since = Date.now(); 196 | evts.forEach(function (evt) { 197 | nodes[0].dt.post(evt.msg, evt.delay, function (err, evId) { 198 | assert.ifError(err); 199 | evt.id = evId; 200 | evt.postDelay = Date.now() - since; 201 | evt.posted = true; 202 | }); 203 | }); 204 | nodes.forEach(function (node) { 205 | node.dt.on('event', function (ev) { 206 | node.numRcvd++; 207 | var elapsed = Date.now() - since; 208 | evts.forEach(function (evt) { 209 | if (evt.msg.msg === ev.msg) { 210 | numRcvd++; 211 | evt.elapsed = elapsed; 212 | evt.rcvd = ev; 213 | evt.rcvdBy = node.id; 214 | evt.order = numRcvd; 215 | } 216 | }); 217 | }); 218 | }); 219 | setTimeout(next, 100); 220 | }, 221 | function (next) { 222 | nodes[0].pub.llen('dt:ch', function (err, reply) { 223 | assert.ifError(err); 224 | assert.equal(reply, 8); 225 | next(); 226 | }); 227 | }, 228 | function (next) { 229 | var numLeft = 0; 230 | nodes.forEach(function (node) { 231 | node.dt.leave(function () { 232 | numLeft ++; 233 | if (numLeft === nodes.length) { 234 | return next(); 235 | } 236 | }); 237 | }); 238 | } 239 | ], function (err, results) { 240 | void(results); 241 | assert.ifError(err); 242 | evts.forEach(function (evt) { 243 | assert.ok(evt.posted); 244 | assert.deepEqual(evt.msg.msg, evt.rcvd.msg); 245 | assert(evt.elapsed < evt.delay + 200); 246 | assert(evt.elapsed > evt.delay); 247 | }); 248 | assert.equal(numRcvd, evts.length); 249 | nodes.forEach(function (node) { 250 | if (node.numRcvd > 0) { 251 | assert.equal(node.numRcvd, 2); 252 | } 253 | }); 254 | done(); 255 | }); 256 | }); 257 | }); 258 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dtimer 2 | 3 | [![NPM version](https://badge.fury.io/js/dtimer.svg)](http://badge.fury.io/js/dtimer) 4 | [![Build Status](https://travis-ci.org/enobufs/dtimer.svg?branch=master)](https://travis-ci.org/enobufs/dtimer) 5 | [![Coverage Status](https://coveralls.io/repos/enobufs/dtimer/badge.png?branch=master)](https://coveralls.io/r/enobufs/dtimer?branch=master) 6 | 7 | Distributed timer backed by Redis. 8 | 9 | ## Why dtimer? 10 | In a clustered server environment, you'd occasionally need to process a task after a certain period of time. The setTimeout() may not be suitable because the process may die for whatever reason, the timed events would also be lost. Ideally, you'd want to store these timed events on a central storage, then have a cluster of listeners handle the due events. If you are already using Redis, then this dtimer would be a handy solution for you. 11 | 12 | ## Installation 13 | $ npm install dtimer 14 | 15 | ## Features 16 | * Timed event emitter and listener across cluster using Redis. 17 | * Supports one or more event listeners across cluster. (round robin) 18 | * Pubsub based event notification (low delay and low network bandwidth usage.) 19 | 20 | ## Requirements 21 | * Requires Redis version 2.6.0 or later (dtimer uses lua) 22 | * The redis module MUST be promisified at module level in advance, even legacy callback style is used. 23 | ```js 24 | var Promise = require('bluebird'); 25 | var redis = Promise.promisifyAll(require('redis')); 26 | ``` 27 | 28 | ## API 29 | 30 | ### Class / Constructor 31 | #### DTimer(id, pub, sub, [option]) - Inherits EventEmitter. 32 | * {string} id - Unique ID representing this node. 33 | * {RedisClient} pub - Redis client (main operation) 34 | * {RedisClient} sub - Redis client for subscribe 35 | * {object} option Options. 36 | * {string} ns Namespace to define a unique event notification domain. Defaults to 'dt'. 37 | * {number} maxEvents The maximum number of events this instance wants to received at a time. Defaults to 8. You may change this value later with the setter, `maxEvents` 38 | * {number} confTimeout Confirmation wait timeout in seconds. Defaults to 10 [sec]. 39 | 40 | > The redis module MUST be promisified before instantiating clients for `pub` and `sub`. See the example below. 41 | 42 | ### Instance method 43 | #### join() - Start listening to events. 44 | ``` 45 | join([cb]) => {Promise} 46 | * cb {function} Optional callback. 47 | * returns Promise if cb is not supplied. 48 | ``` 49 | 50 | #### leave() - Stop listening to events. 51 | ``` 52 | leave([cb]) => {Promise} 53 | * cb {function} Optional callback. 54 | * returns Promise if cb is not supplied. 55 | ``` 56 | 57 | #### post() - Post an event. 58 | ``` 59 | post(ev, delay [, cb]) => {Promise} 60 | * {object} ev - Event data. 61 | * {string} [ev.id] - User provided event ID. If not present, dtimer will automatically 62 | assign an ID using uuid. 63 | * {number} [ev.maxRetries] - Maximum number of retries that occur if confirm() is not 64 | made within confTimeout [sec]. If not present, it defaults to 0 (no retry). 65 | * {number} delay - Delay value in milliseconds. 66 | * {function} [cb] - Callback made when the post operation is complete. 67 | * returns Promise if cb is not supplied. 68 | * Resolved value: evId {string} - Event ID assigned to the posted event. If ev object 69 | already had id property, this evId is identical to ev.id always. 70 | ``` 71 | 72 | > The `ev` object may have user-defined properties as its own properties, however, the following properties are reserved and used by dtimer; 'id', 'maxRetries' and '_numRetries'. If your application needs to use these names (for application specific use), then consider putting all user-defined event object inside the `ev` like this: 73 | 74 | ```js 75 | { 76 | id: '25723fdd-4434-4cbd-b579-4693e221ec73', 77 | maxRetries: 3, 78 | // _numRetries: 0 // added by dtimer before the event was fired 79 | data: { /*...*/ } // user-defined event object 80 | } 81 | ``` 82 | 83 | 84 | #### peek() - Peek an event scheduled. 85 | ``` 86 | peek(evId [, cb]) => {Promise} 87 | * {string} evId - The event ID to be peeked. 88 | * {function} [cb] - Callback made when the operation is complete. 89 | * returns Promise if cb is not supplied. 90 | * Resolved value: results {array} An array of results. 91 | * results[0] {number} Time to expire in milliseconds, or null if the event does 92 | not exit. 93 | * results[1] {object} Event object, or null if the event does not exit. 94 | ``` 95 | 96 | #### cancel() - Cancel an event by its event ID. 97 | ``` 98 | cancel(evId [, cb]) => {Promise} 99 | * {string} evId - The event ID to be canceled. 100 | * {function} [cb] - Callback made when the operation is complete. 101 | * returns Promise if cb is not supplied. 102 | * Resolved value {number} 0: the event ID not found. 1: the event has been canceled. 103 | ``` 104 | 105 | #### confirm() - Confirm that specified event has been processed. 106 | ``` 107 | confirm(evId [, cb]) 108 | * {string} evId - The event ID to be confirmed. 109 | * {function} [cb] - Callback made when the operation is complete. 110 | * returns Promise if cb is not supplied. 111 | * Resolved value {number} 0: the event ID not found. 1: the event has been confirmed. 112 | ``` 113 | 114 | #### changeDelay() - Change delay of specified event. 115 | ``` 116 | changeDelay(evId, delay, [, cb]) => {Promise} 117 | * {string} evId - The event ID for which the delay will be changed. 118 | * {number} delay - New delay (in milliseconds relative to the current time). 119 | * {function} [cb] - Callback made when the operation is complete. 120 | * returns Promise if cb is not supplied. 121 | * Resolved value {number} 0: the event ID not found. 1: the delay has been updated. 122 | ``` 123 | 124 | #### upcoming() - Retrieve upcoming events. 125 | This method is provided for diagnostic purpose only and the use of this method in production is highly discouraged unless the number of events retrieved is reasonably small. Cost of this operation is O(N), where N is the number events that would be retrieved. 126 | ``` 127 | upcoming([option] [, cb]) => {Promise} 128 | * {object} option - Options 129 | * {number} offset Offset expiration time in msec from which events are retrieved. 130 | This defaults to the current (redis-server) time (-1). 131 | * {number} duration Time length [msec] from offset time for which events are 132 | retrieved. This defaults to '+inf' (-1). 133 | * {number} limit Maximum number of events to be retrieved. This defaults to 134 | `no limit` (-1). 135 | * {function} [cb] - Callback made when upcoming operation is complete. 136 | The callback function takes following args: 137 | * {Error} err - Error object. Null is set on success. 138 | * {object} events - List of objects that met the given criteria. 139 | * returns Promise if cb is not supplied. 140 | ``` 141 | 142 | ##### Example of retrieved events by upcoming(): 143 | 144 | ``` 145 | { 146 | "25723fdd-4434-4cbd-b579-4693e221ec73": { 147 | "expireAt": 1410502530320, 148 | "event": { 149 | "msg": "hello" 150 | } 151 | }, 152 | "24bbef35-8014-4107-803c-5ff4b858a5ad": { 153 | "expireAt": 1410502531321, 154 | "event": { 155 | "msg": "hello" 156 | } 157 | } 158 | } 159 | ``` 160 | 161 | 162 | ### Instance member (getter/setter) 163 | * {number} maxEvents (getter&setter) - The max number of events this node can grab at a time. The attempt to set it to 0 or negative value result in setting it to the original value supplied in the option field, or the default (8). 164 | 165 | 166 | ### Event types 167 | 168 | #### Event: 'event' - Emitted when an event is received. 169 | The handler will be called with the following argument: 170 | 171 | ``` 172 | * ev {object} Event object. 173 | * ev.id {string} Event ID. 174 | * ev.maxRetries {number} Max retries specified when this event was posted. 175 | * ev._numRetries {number} Number of retries made before this event occured. 176 | ``` 177 | 178 | #### Event: 'error' - Emitted when an error occurred. 179 | The handler will be called with the following argument: 180 | 181 | ``` 182 | * err {Error} Error object. 183 | ``` 184 | 185 | ## Example 186 | 187 | ```js 188 | var DTimer = require('dtimer').DTimer; 189 | var Promise = require('bluebird'); 190 | var redis = Promise.promisifyAll(require('redis')); // module level promisification 191 | var pub = redis.createClient(); 192 | var sub = redis.createClient(); 193 | var dt = new DTimer('ch1', pub, sub) 194 | dt.on('event', function (ev) { 195 | // do something with ev 196 | 197 | // If the posted event has `maxRetries` property set to a number greater than 0, 198 | // you must call confirm() with its event ID. If not confirmed, the event will 199 | // fire in `confTimeout` (default 10 sec) again, until the number of retries 200 | // becomes `maxRetries`. 201 | dt.confirm(ev.id, function (err) { 202 | // confirmed. 203 | }); 204 | }) 205 | dt.on('error', function (err) { 206 | // handle error 207 | }) 208 | dt.join(function (err) { 209 | if (err) { 210 | // join failed 211 | return; 212 | } 213 | // join successfully 214 | }) 215 | dt.post({id: 'myId', maxRetries: 3, msg:'hello'}, 200, function (err) { 216 | if (err) { 217 | // failed to post event 218 | return; 219 | } 220 | // posted the event successfully 221 | 222 | // If you need to cancel this event, then do: 223 | //dt.cancel('myId', function (err) {...}); 224 | }) 225 | ``` 226 | 227 | ## Tips 228 | 229 | * You do not have to join to post. By calling `join()`, you are declaring yourself as a listener to consume due events. 230 | * Under the hood, more than one event may be retrieved. You may change the maximum number of events (using the setter, DTimer#maxEvents) to be retrieved at once to optimize memory usage and/or overall performance. 231 | * To control incoming event traffic, use DTimer#leave() and then DTimer#join() again. Note that you may still receive some events after calling leave() if there are remaining events inside DTimer that have already been received from Redis. 232 | -------------------------------------------------------------------------------- /test/ErrorTests.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var DTimer = require('..').DTimer; 4 | var async = require('async'); 5 | var assert = require('assert'); 6 | var sinon = require('sinon'); 7 | var Promise = require('bluebird'); 8 | var redis = Promise.promisifyAll(require("redis")); 9 | 10 | describe('Error tests', function () { 11 | var pub = null; 12 | var sub = null; 13 | var dt = null; 14 | var sandbox; 15 | 16 | before(function () { 17 | sandbox = sinon.sandbox.create(); 18 | }); 19 | 20 | beforeEach(function (done) { 21 | var conns = 0; 22 | pub = redis.createClient(); 23 | pub.once('ready', function () { conns++; }); 24 | sub = redis.createClient(); 25 | sub.once('ready', function () { conns++; }); 26 | async.whilst( 27 | function () { return (conns < 2); }, 28 | function (next) { 29 | setTimeout(next, 100); 30 | }, 31 | function (err) { 32 | if (err) { 33 | return void(done(err)); 34 | } 35 | async.series([ 36 | function (next) { 37 | pub.select(9, next); 38 | }, 39 | function (next) { 40 | pub.flushdb(next); 41 | } 42 | ], function (err) { 43 | if (err) { return void(done(err)); } 44 | dt = new DTimer('ch1', pub, sub); 45 | setTimeout(done, 100); // wait loading to complete 46 | }); 47 | } 48 | ); 49 | }); 50 | 51 | afterEach(function () { 52 | dt.removeAllListeners(); 53 | dt = null; 54 | pub.removeAllListeners(); 55 | pub.end(); 56 | pub = null; 57 | sub.removeAllListeners(); 58 | sub.end(); 59 | sub = null; 60 | sandbox.restore(); 61 | }); 62 | 63 | it('#join', function (done) { 64 | sandbox.stub(pub, 'timeAsync', function () { 65 | return Promise.reject(new Error('fail error')); 66 | }); 67 | 68 | dt.join(function (err) { 69 | assert.ok(err); 70 | done(); 71 | }); 72 | }); 73 | 74 | it('#leave', function (done) { 75 | sandbox.stub(pub, 'timeAsync', function () { 76 | return Promise.reject(new Error('fail error')); 77 | }); 78 | 79 | dt.leave(function (err) { 80 | assert.ok(err); 81 | done(); 82 | }); 83 | }); 84 | 85 | it('#post', function (done) { 86 | sandbox.stub(pub, 'timeAsync', function () { 87 | return Promise.reject(new Error('fail error')); 88 | }); 89 | 90 | dt.post({}, 100, function (err) { 91 | assert.ok(err); 92 | done(); 93 | }); 94 | }); 95 | 96 | it('#cancel', function (done) { 97 | sandbox.stub(pub, 'timeAsync', function () { 98 | return Promise.reject(new Error('fail error')); 99 | }); 100 | 101 | dt.cancel(3, function (err) { 102 | assert.ok(err); 103 | done(); 104 | }); 105 | }); 106 | 107 | it('#cancel - multi error', function (done) { 108 | sandbox.stub(pub, 'multi', function () { 109 | var multi = { 110 | evalsha: function () { return multi; }, 111 | execAsync: function () { 112 | return Promise.reject(new Error('fake error')); 113 | } 114 | }; 115 | return multi; 116 | }); 117 | 118 | dt.cancel('myEvent', function (err) { 119 | assert.ok(err); 120 | assert.equal(err.name, 'Error'); 121 | done(); 122 | }); 123 | }); 124 | 125 | it('#confirm - error with time command', function (done) { 126 | sandbox.stub(pub, 'timeAsync', function () { 127 | return Promise.reject(new Error('fail error')); 128 | }); 129 | 130 | dt.confirm('myEvent', function (err) { 131 | assert.ok(err); 132 | done(); 133 | }); 134 | }); 135 | 136 | it('#confirm - multi error', function (done) { 137 | sandbox.stub(pub, 'multi', function () { 138 | var multi = { 139 | evalsha: function () { return multi; }, 140 | execAsync: function () { 141 | return Promise.reject(new Error('fake error')); 142 | } 143 | }; 144 | return multi; 145 | }); 146 | 147 | dt.confirm('myEvent', function (err) { 148 | assert.ok(err); 149 | assert.equal(err.name, 'Error'); 150 | done(); 151 | }); 152 | }); 153 | 154 | it('#changeDelay - error with time command', function (done) { 155 | sandbox.stub(pub, 'timeAsync', function () { 156 | return Promise.reject(new Error('fail error')); 157 | }); 158 | 159 | dt.changeDelay('myEvent', 1000, function (err) { 160 | assert.ok(err); 161 | done(); 162 | }); 163 | }); 164 | 165 | it('#changeDelay - multi error', function (done) { 166 | sandbox.stub(pub, 'multi', function () { 167 | var multi = { 168 | evalsha: function () { return multi; }, 169 | execAsync: function () { 170 | return Promise.reject(new Error('fake error')); 171 | } 172 | }; 173 | return multi; 174 | }); 175 | 176 | dt.changeDelay('myEvent', 1000, function (err) { 177 | assert.ok(err); 178 | assert.equal(err.name, 'Error'); 179 | done(); 180 | }); 181 | }); 182 | 183 | it('#_onTimeout', function (done) { 184 | sandbox.stub(pub, 'timeAsync', function () { 185 | return Promise.reject(new Error('fail error')); 186 | }); 187 | dt.on('error', function (err) { 188 | assert.ok(err); 189 | done(); 190 | }); 191 | dt._onTimeout(); 192 | }); 193 | 194 | it('#join - multi error', function (done) { 195 | sandbox.stub(pub, 'multi', function () { 196 | var m = { 197 | lrem: function () { return this; }, 198 | lpush: function () { return this; }, 199 | zadd: function () { return this; }, 200 | zrem: function () { return this; }, 201 | hset: function () { return this; }, 202 | hdel: function () { return this; }, 203 | evalsha:function () { return this; }, 204 | execAsync: function () { 205 | return Promise.reject(new Error('fake err')); 206 | }, 207 | }; 208 | return m; 209 | }); 210 | 211 | dt.join(function (err) { 212 | assert.ok(err); 213 | assert.equal(err.name, 'Error'); 214 | done(); 215 | }); 216 | }); 217 | 218 | it('#post - multi error', function (done) { 219 | sandbox.stub(pub, 'multi', function () { 220 | var m = { 221 | lrem: function () { return this; }, 222 | lpush: function () { return this; }, 223 | zadd: function () { return this; }, 224 | zrem: function () { return this; }, 225 | hset: function () { return this; }, 226 | hdel: function () { return this; }, 227 | evalsha:function () { return this; }, 228 | execAsync: function () { 229 | return Promise.reject(new Error('fake err')); 230 | }, 231 | }; 232 | return m; 233 | }); 234 | 235 | dt.post({}, 200, function (err) { 236 | assert.ok(err); 237 | assert.equal(err.name, 'Error'); 238 | done(); 239 | }); 240 | }); 241 | 242 | it('#post - multi (in-result) error', function (done) { 243 | sandbox.stub(pub, 'multi', function () { 244 | var m = { 245 | lrem: function () { return this; }, 246 | lpush: function () { return this; }, 247 | zadd: function () { return this; }, 248 | zrem: function () { return this; }, 249 | hset: function () { return this; }, 250 | hdel: function () { return this; }, 251 | evalsha:function () { return this; }, 252 | execAsync: function () { 253 | return Promise.resolve(['ERR fakeed', 1, 1]); 254 | }, 255 | }; 256 | return m; 257 | }); 258 | 259 | dt.post({}, 200, function (err) { 260 | assert.ok(err); 261 | assert.equal(err.name, 'Error'); 262 | done(); 263 | }); 264 | }); 265 | 266 | it('#_onTimeout - evalsha error', function (done) { 267 | sandbox.stub(global, 'setTimeout', function (fn, interval) { 268 | assert(typeof fn === 'function'); 269 | assert.equal(interval, 3000); 270 | done(); 271 | }); 272 | sandbox.stub(pub, 'evalsha', function () { 273 | var cb = arguments[11]; 274 | cb(new Error('fail error')); 275 | }); 276 | 277 | dt._onTimeout(); 278 | }); 279 | 280 | it('#_onTimeout - evalsha error (2)', function (done) { 281 | sandbox.stub(global, 'setTimeout', function (fn, interval) { 282 | assert(typeof fn === 'function'); 283 | assert.equal(interval, 1234); 284 | done(); 285 | }); 286 | sandbox.stub(pub, 'evalsha', function () { 287 | var cb = arguments[11]; 288 | cb(null, [ ['{bad]'], 1234]); 289 | }); 290 | 291 | dt._onTimeout(); 292 | }); 293 | 294 | describe('#upcoming', function () { 295 | beforeEach(function (done) { 296 | dt.join(done); 297 | }); 298 | 299 | it('force _redisTime return error', function (done) { 300 | sandbox.stub(dt, '_redisTime', function (c) { 301 | void(c); 302 | return Promise.reject(new Error('fake error')); 303 | }); 304 | dt.upcoming(function (err) { 305 | assert.ok(err); 306 | done(); 307 | }); 308 | }); 309 | 310 | it('force _pub.zrangebyscoreAsync return error', function (done) { 311 | sandbox.stub(dt._pub, 'zrangebyscoreAsync', function (args) { 312 | void(args); 313 | return Promise.reject(new Error('fake error')); 314 | }); 315 | dt.upcoming(function (err) { 316 | assert.ok(err); 317 | done(); 318 | }); 319 | }); 320 | 321 | it('force _pub.hmgetAsync return error', function (done) { 322 | async.series([ 323 | function (next) { 324 | dt.post({ msg: 'bye' }, 1000, next); 325 | }, 326 | function (next) { 327 | sandbox.stub(dt._pub, 'hmgetAsync', function (args) { 328 | void(args); 329 | return Promise.reject(new Error('fake error')); 330 | }); 331 | 332 | dt.upcoming(function (err) { 333 | assert.ok(err); 334 | next(); 335 | }); 336 | }, 337 | function (next) { 338 | dt.leave(function () { 339 | next(); 340 | }); 341 | } 342 | ], done); 343 | }); 344 | }); 345 | }); 346 | -------------------------------------------------------------------------------- /lib/dtimer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var fs = require('fs'); 4 | var util = require('util'); 5 | var events = require('events'); 6 | var _und = require('underscore'); 7 | var debug = require('debug')('dtimer'); 8 | var uuid = require('uuid'); 9 | var Promise = require('bluebird'); 10 | 11 | Promise.promisifyAll(require('redis')); 12 | 13 | // defaults 14 | var defaults = { 15 | ns: 'dt', 16 | maxEvents: 8, 17 | readyTimeout: 30, // in seconds 18 | confTimeout: 10 // in seconds 19 | }; 20 | 21 | // scripts 22 | var scripts = { 23 | update: { 24 | script: fs.readFileSync(__dirname + '/lua/update.lua', 'utf8') 25 | }, 26 | cancel: { 27 | script: fs.readFileSync(__dirname + '/lua/cancel.lua', 'utf8') 28 | }, 29 | changeDelay: { 30 | script: fs.readFileSync(__dirname + '/lua/changeDelay.lua', 'utf8') 31 | } 32 | }; 33 | 34 | // Workaround for in-result errors for multi operation. 35 | // This will not be necessary with redis@>=2.0.0. 36 | function throwIfMultiError(results) { 37 | results.forEach(function (res) { 38 | if (typeof res === 'string' && res.indexOf('ERR') === 0) { 39 | throw new Error(res); 40 | } 41 | }); 42 | } 43 | 44 | 45 | // Redis key name policy 46 | // Global hash: $ns + ':' + 'gl', hash {field-name, value} 47 | // o lastId 48 | // Channel table : $ns + ':' + 'chs', hash-set {last-ts, node-id} 49 | // Event table : $ns + ':' + 'evs', hash {field-name, value} 50 | 51 | function DTimer(id, pub, sub, option) { 52 | var self = this; 53 | this._timer = null; 54 | this._option = _und.defaults(option || {}, defaults); 55 | this._pub = pub; 56 | if (!this._pub) { 57 | throw new Error('Redis client (pub) is missing'); 58 | } 59 | this._sub = sub; 60 | if (this._sub) { 61 | this._sub.on("message", this._onSubMessage.bind(this)); 62 | if (typeof id !== 'string' || id.length === 0) { 63 | throw new Error('The id must be non-empty string'); 64 | } 65 | } else { 66 | id = 'post-only'; 67 | } 68 | this._keys = { 69 | gl: self._option.ns + ':gl', 70 | ch: self._option.ns + ':ch', 71 | ei: self._option.ns + ':ei', 72 | ed: self._option.ns + ':ed', 73 | et: self._option.ns + ':et' 74 | }; 75 | this._id = this._keys.ch + ':' + id; // subscriber channel 76 | this._lur = require('lured').create(this._pub, scripts); 77 | this._lur.load(function (err) { 78 | if (err) { 79 | debug(self._id+': lua loading failed: ' + err.name); 80 | self.emit('error', err); 81 | return; 82 | } 83 | debug(self._id+': lua loading successful'); 84 | }); 85 | this._maxEvents = this._option.maxEvents; 86 | 87 | // Define getter 'maxEvents' 88 | this.__defineGetter__("maxEvents", function () { 89 | return this._maxEvents; 90 | }); 91 | // Define setter 'maxEvents' 92 | this.__defineSetter__("maxEvents", function (num) { 93 | this._maxEvents = (num > 0)? num:this._option.maxEvents; 94 | }); 95 | } 96 | 97 | util.inherits(DTimer, events.EventEmitter); 98 | 99 | /** @private */ 100 | DTimer.prototype._onSubMessage = function (chId, msg) { 101 | void(chId); 102 | try { 103 | var o = JSON.parse(msg); 104 | if (typeof o.interval === 'number') { 105 | if (this._timer) { 106 | clearTimeout(this._timer); 107 | } 108 | debug(this._id+': new interval (1) ' + o.interval); 109 | this._timer = setTimeout(this._onTimeout.bind(this), o.interval); 110 | } 111 | } catch (e) { 112 | debug('Malformed message:', msg); 113 | this.emit('error', e); 114 | } 115 | }; 116 | 117 | 118 | DTimer.prototype.join = function (cb) { 119 | var self = this; 120 | return new Promise(function (resolve, reject) { 121 | if (!self._sub) { 122 | return reject(new Error('Can not join without redis client (sub)')); 123 | } 124 | 125 | self._sub.subscribe(self._id); 126 | self._sub.once('subscribe', function () { 127 | resolve(); 128 | }); 129 | }) 130 | .then(function () { 131 | return self._redisTime(); 132 | }) 133 | .then(function (now) { 134 | return self._pub.multi() 135 | .lrem(self._keys.ch, 0, self._id) 136 | .lpush(self._keys.ch, self._id) 137 | .evalsha( 138 | scripts.update.sha, 139 | 5, 140 | self._keys.gl, 141 | self._keys.ch, 142 | self._keys.ei, 143 | self._keys.ed, 144 | self._keys.et, 145 | '', 146 | now, 147 | 0, 148 | self._option.confTimeout) 149 | .execAsync() 150 | .then(function (replies) { 151 | throwIfMultiError(replies); 152 | /* istanbul ignore if */ 153 | if (self._timer) { 154 | clearTimeout(self._timer); 155 | } 156 | debug(self._id+': new interval (2) ' + replies[2][1]); 157 | self._timer = setTimeout(self._onTimeout.bind(self), replies[2][1]); 158 | }); 159 | }) 160 | .nodeify(cb); 161 | }; 162 | 163 | DTimer.prototype._redisTime = function () { 164 | return this._pub.timeAsync() 165 | .then(function (result) { 166 | return result[0] * 1000 + Math.floor(result[1] / 1000); 167 | }); 168 | }; 169 | 170 | DTimer.prototype.leave = function (cb) { 171 | if (!this._sub) { 172 | return Promise.reject(new Error('Can not leave without redis client (sub)')).nodeify(cb); 173 | 174 | } 175 | var self = this; 176 | 177 | if (this._timer) { 178 | clearTimeout(this._timer); 179 | this._timer = null; 180 | } 181 | 182 | return this._redisTime() 183 | .then(function (now) { 184 | return self._pub.multi() 185 | .lrem(self._keys.ch, 0, self._id) 186 | .evalsha( 187 | scripts.update.sha, 188 | 5, 189 | self._keys.gl, 190 | self._keys.ch, 191 | self._keys.ei, 192 | self._keys.ed, 193 | self._keys.et, 194 | '', 195 | now, 196 | 0, 197 | self._option.confTimeout) 198 | .execAsync() 199 | .then(throwIfMultiError); 200 | }) 201 | .finally(function () { 202 | self._sub.unsubscribe(self._id); 203 | return new Promise(function (resolve) { 204 | self._sub.once('unsubscribe', function () { 205 | resolve(); 206 | }); 207 | }); 208 | }) 209 | .nodeify(cb); 210 | }; 211 | 212 | DTimer.prototype.post = function (ev, delay, cb) { 213 | var self = this; 214 | var evId; 215 | 216 | if (typeof delay !== 'number') { 217 | throw new Error('delay argument must be of type number'); 218 | } 219 | 220 | // Copy event. 221 | ev = JSON.parse(JSON.stringify(ev)); 222 | 223 | if (typeof ev !== 'object') { 224 | throw new Error('event data must be of type object'); 225 | } 226 | 227 | if (ev.hasOwnProperty('id')) { 228 | if (typeof ev.id !== 'string' || ev.id.length === 0) { 229 | throw new Error('event ID must be a non-empty string'); 230 | } 231 | } else { 232 | ev.id = uuid.v4(); 233 | } 234 | evId = ev.id; 235 | 236 | if (ev.hasOwnProperty('maxRetries')) { 237 | if (typeof ev.maxRetries !== 'number') { 238 | throw new Error('maxRetries must be a number'); 239 | } 240 | } else { 241 | ev.maxRetries = 0; 242 | } 243 | 244 | var msg = JSON.stringify(ev); 245 | 246 | return this._redisTime() 247 | .then(function (now) { 248 | return self._pub.multi() 249 | .zadd(self._keys.ei, now+delay, evId) 250 | .hset(self._keys.ed, evId, msg) 251 | .evalsha( 252 | scripts.update.sha, 253 | 5, 254 | self._keys.gl, 255 | self._keys.ch, 256 | self._keys.ei, 257 | self._keys.ed, 258 | self._keys.et, 259 | '', 260 | now, 261 | 0, 262 | self._option.confTimeout) 263 | .execAsync() 264 | .then(function (results) { 265 | throwIfMultiError(results); 266 | return evId; 267 | }); 268 | }) 269 | .nodeify(cb); 270 | }; 271 | 272 | DTimer.prototype.peek = function (evId, cb) { 273 | var self = this; 274 | 275 | return this._redisTime() 276 | .then(function (now) { 277 | return self._pub.multi() 278 | .zscore(self._keys.ei, evId) 279 | .hget(self._keys.ed, evId) 280 | .execAsync() 281 | .then(function (results) { 282 | throwIfMultiError(results); 283 | if (results[0] === null || results[1] === null) { 284 | return [null, null]; 285 | } 286 | return [ 287 | Math.max(parseInt(results[0]) - now, 0), 288 | JSON.parse(results[1]) 289 | ]; 290 | }); 291 | }) 292 | .nodeify(cb); 293 | }; 294 | 295 | DTimer.prototype.cancel = function (evId, cb) { 296 | var self = this; 297 | 298 | return this._redisTime() 299 | .then(function (now) { 300 | return self._pub.multi() 301 | .evalsha( 302 | scripts.cancel.sha, 303 | 2, 304 | self._keys.ei, 305 | self._keys.ed, 306 | evId) 307 | .evalsha( 308 | scripts.update.sha, 309 | 5, 310 | self._keys.gl, 311 | self._keys.ch, 312 | self._keys.ei, 313 | self._keys.ed, 314 | self._keys.et, 315 | '', 316 | now, 317 | 0, 318 | self._option.confTimeout) 319 | .execAsync() 320 | .then(function (results) { 321 | throwIfMultiError(results); 322 | return results[0]; 323 | }); 324 | }) 325 | .nodeify(cb); 326 | }; 327 | 328 | DTimer.prototype.confirm = function (evId, cb) { 329 | var self = this; 330 | 331 | return this._redisTime() 332 | .then(function (now) { 333 | return self._pub.multi() 334 | .evalsha( 335 | scripts.cancel.sha, // reuse cancel.lua script 336 | 2, 337 | self._keys.et, 338 | self._keys.ed, 339 | evId) 340 | .evalsha( 341 | scripts.update.sha, 342 | 5, 343 | self._keys.gl, 344 | self._keys.ch, 345 | self._keys.ei, 346 | self._keys.ed, 347 | self._keys.et, 348 | '', 349 | now, 350 | 0, 351 | self._option.confTimeout) 352 | .execAsync() 353 | .then(function (results) { 354 | throwIfMultiError(results); 355 | return results[0]; 356 | }); 357 | }) 358 | .nodeify(cb); 359 | }; 360 | 361 | DTimer.prototype.changeDelay = function (evId, delay, cb) { 362 | var self = this; 363 | 364 | if (typeof delay !== 'number') { 365 | throw new Error('delay argument must be of type number'); 366 | } 367 | 368 | return this._redisTime() 369 | .then(function (now) { 370 | return self._pub.multi() 371 | .evalsha( 372 | scripts.changeDelay.sha, 373 | 1, 374 | self._keys.ei, 375 | evId, 376 | now+delay) 377 | .evalsha( 378 | scripts.update.sha, 379 | 5, 380 | self._keys.gl, 381 | self._keys.ch, 382 | self._keys.ei, 383 | self._keys.ed, 384 | self._keys.et, 385 | '', 386 | now, 387 | 0, 388 | self._option.confTimeout) 389 | .execAsync() 390 | .then(function (results) { 391 | throwIfMultiError(results); 392 | return results[0]; 393 | }); 394 | }) 395 | .nodeify(cb); 396 | }; 397 | 398 | DTimer.prototype._onTimeout = function () { 399 | var self = this; 400 | this._timer = null; 401 | this._redisTime() 402 | .then(function (now) { 403 | var interval; 404 | return self._pub.evalshaAsync( 405 | scripts.update.sha, 406 | 5, 407 | self._keys.gl, 408 | self._keys.ch, 409 | self._keys.ei, 410 | self._keys.ed, 411 | self._keys.et, 412 | self._id, 413 | now, 414 | self._maxEvents, 415 | self._option.confTimeout) 416 | .then(function (replies) { 417 | interval = replies[1]; 418 | if (replies[0].length > 0) { 419 | replies[0].forEach(function (sev) { 420 | var ev; 421 | try { 422 | ev = JSON.parse(sev); 423 | } catch (e) { 424 | debug(self._id+': fail to parse event. ' + JSON.stringify(e)); 425 | return; 426 | } 427 | self.emit('event', ev); 428 | }); 429 | } 430 | }, function (err) { 431 | interval = 3000; 432 | debug(self._id+': update failed: ' + err.name); 433 | }) 434 | .finally(function () { 435 | if (!self._timer) { 436 | debug(self._id+': new interval (3) ' + interval); 437 | self._timer = setTimeout(self._onTimeout.bind(self), interval); 438 | } 439 | }); 440 | }, function (err) { 441 | self.emit('error', err); 442 | }); 443 | }; 444 | 445 | DTimer.prototype.upcoming = function (option, cb) { 446 | var self = this; 447 | var defaults = { 448 | offset: -1, 449 | duration: -1, // +inf 450 | limit: -1 // +inf 451 | }; 452 | var _option; 453 | 454 | if (typeof option !== 'object') { 455 | cb = option; 456 | _option = defaults; 457 | } else { 458 | _option = _und.defaults(option, defaults); 459 | } 460 | 461 | return this._redisTime() 462 | .then(function (now) { 463 | var args = [ self._keys.ei ]; 464 | var offset = 0; 465 | if (typeof _option.offset !== 'number' || _option.offset < 0) { 466 | args.push(0); 467 | } else { 468 | args.push(now + _option.offset); 469 | offset = _option.offset; 470 | } 471 | if (typeof _option.duration !== 'number' || _option.duration < 0) { 472 | args.push('+inf'); 473 | } else { 474 | args.push(now + offset + _option.duration); 475 | } 476 | args.push('WITHSCORES'); 477 | if (typeof _option.limit === 'number' && _option.limit > 0) { 478 | args.push('LIMIT'); 479 | args.push(0); 480 | args.push(_option.limit); 481 | } 482 | debug('upcoming args: ' + JSON.stringify(args)); 483 | 484 | return self._pub.zrangebyscoreAsync(args) 485 | .then(function (results) { 486 | if (results.length === 0) { 487 | return {}; 488 | } 489 | 490 | var out = []; 491 | args = [ self._keys.ed ]; 492 | for (var i = 0; i < results.length; i += 2) { 493 | out.push({ expireAt: parseInt(results[i+1]), id: results[i]}); 494 | args.push(results[i]); 495 | } 496 | 497 | return self._pub.hmgetAsync(args) 498 | .then(function (results) { 499 | var outObj = {}; 500 | results.forEach(function (evStr, index){ 501 | /* istanbul ignore if */ 502 | if (!evStr) { 503 | return; 504 | } 505 | /* istanbul ignore next */ 506 | try { 507 | var event = JSON.parse(evStr); 508 | } catch (e) { 509 | debug(self._id+': fail to parse event. ' + JSON.stringify(e)); 510 | return; 511 | } 512 | outObj[out[index].id] = { expireAt : out[index].expireAt, event: event }; 513 | }); 514 | return outObj; 515 | }); 516 | }); 517 | }) 518 | .nodeify(cb); 519 | }; 520 | 521 | module.exports.DTimer = DTimer; 522 | -------------------------------------------------------------------------------- /test/SingleNode.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var DTimer = require('..').DTimer; 4 | var async = require('async'); 5 | var assert = require('assert'); 6 | var debug = require('debug')('dtimer'); 7 | var Promise = require('bluebird'); 8 | var redis = Promise.promisifyAll(require("redis")); 9 | 10 | describe('Single node', function () { 11 | var pub = null; 12 | var sub = null; 13 | var dt = null; 14 | 15 | before(function () { 16 | }); 17 | 18 | beforeEach(function (done) { 19 | var conns = 0; 20 | pub = redis.createClient(); 21 | pub.once('ready', function () { conns++; }); 22 | sub = redis.createClient(); 23 | sub.once('ready', function () { conns++; }); 24 | async.whilst( 25 | function () { return (conns < 2); }, 26 | function (next) { 27 | setTimeout(next, 100); 28 | }, 29 | function (err) { 30 | if (err) { 31 | return void(done(err)); 32 | } 33 | async.series([ 34 | function (next) { 35 | pub.select(9, next); 36 | }, 37 | function (next) { 38 | pub.flushdb(next); 39 | } 40 | ], function (err) { 41 | if (err) { return void(done(err)); } 42 | dt = new DTimer('ch1', pub, sub, { confTimeout: 1 }); 43 | done(); 44 | }); 45 | } 46 | ); 47 | }); 48 | 49 | afterEach(function () { 50 | dt.removeAllListeners(); 51 | dt = null; 52 | pub.removeAllListeners(); 53 | pub.end(); 54 | pub = null; 55 | sub.removeAllListeners(); 56 | sub.end(); 57 | sub = null; 58 | }); 59 | 60 | it('Post and receive one event', function (done) { 61 | var evt = { msg: 'hello' }; 62 | var delay = 500; 63 | async.series([ 64 | function (next) { 65 | dt.join(function () { 66 | next(); 67 | }); 68 | }, 69 | function (next) { 70 | var since = Date.now(); 71 | var numEvents = 0; 72 | dt.post(evt, delay, function (err) { 73 | assert.ifError(err); 74 | }); 75 | dt.on('event', function (ev) { 76 | var elapsed = Date.now() - since; 77 | numEvents++; 78 | assert.deepEqual(ev.msg, evt.msg); 79 | assert(elapsed < delay * 1.1); 80 | assert(elapsed > delay * 0.9); 81 | }); 82 | setTimeout(function () { 83 | assert.equal(numEvents, 1); 84 | next(); 85 | }, 1000); 86 | }, 87 | function (next) { 88 | dt.leave(function () { 89 | next(); 90 | }); 91 | } 92 | ], function (err, results) { 93 | void(results); 94 | done(err); 95 | }); 96 | }); 97 | 98 | it('Post and receive one event with id', function (done) { 99 | var evt = { id: 'my_event', maxRetries: 0, msg: 'hello' }; 100 | var delay = 500; 101 | async.series([ 102 | function (next) { 103 | dt.join(function () { 104 | next(); 105 | }); 106 | }, 107 | function (next) { 108 | var since = Date.now(); 109 | var numEvents = 0; 110 | dt.post(evt, delay, function (err, evId) { 111 | assert.ifError(err); 112 | assert.equal(evId, evt.id); 113 | }); 114 | dt.on('event', function (ev) { 115 | var elapsed = Date.now() - since; 116 | numEvents++; 117 | assert.strictEqual(ev.msg, evt.msg); 118 | assert(elapsed < delay * 1.1); 119 | assert(elapsed > delay * 0.9); 120 | }); 121 | setTimeout(function () { 122 | assert.equal(numEvents, 1); 123 | next(); 124 | }, 1000); 125 | }, 126 | function (next) { 127 | dt.leave(function () { 128 | next(); 129 | }); 130 | } 131 | ], function (err, results) { 132 | void(results); 133 | done(err); 134 | }); 135 | }); 136 | 137 | it('Post and receive many events', function (done) { 138 | var evts = [ 139 | { msg: { msg: 'msg0' }, delay: 10 }, 140 | { msg: { msg: 'msg1' }, delay: 10 }, 141 | { msg: { msg: 'msg2' }, delay: 10 }, 142 | { msg: { msg: 'msg3' }, delay: 10 }, 143 | { msg: { msg: 'msg4' }, delay: 10 }, 144 | { msg: { msg: 'msg5' }, delay: 10 }, 145 | { msg: { msg: 'msg6' }, delay: 10 }, 146 | { msg: { msg: 'msg7' }, delay: 10 }, 147 | { msg: { msg: 'msg8' }, delay: 10 }, 148 | { msg: { msg: 'msg9' }, delay: 10 } 149 | ]; 150 | var numRcvd = 0; 151 | async.series([ 152 | function (next) { 153 | dt.join(function () { 154 | next(); 155 | }); 156 | }, 157 | function (next) { 158 | var since = Date.now(); 159 | evts.forEach(function (evt) { 160 | dt.post(evt.msg, evt.delay, function (err, evId) { 161 | assert.ifError(err); 162 | evt.id = evId; 163 | evt.postDelay = Date.now() - since; 164 | evt.posted = true; 165 | }); 166 | }); 167 | dt.on('event', function (ev) { 168 | var elapsed = Date.now() - since; 169 | evts.forEach(function (evt) { 170 | if (evt.msg.msg === ev.msg) { 171 | numRcvd++; 172 | evt.elapsed = elapsed; 173 | evt.rcvd = ev; 174 | evt.order = numRcvd; 175 | } 176 | }); 177 | }); 178 | setTimeout(next, 100); 179 | }, 180 | function (next) { 181 | dt.leave(function () { 182 | next(); 183 | }); 184 | } 185 | ], function (err, results) { 186 | void(results); 187 | evts.forEach(function (evt) { 188 | assert.ok(evt.posted); 189 | assert.strictEqual(evt.msg.msg, evt.rcvd.msg); 190 | assert(evt.elapsed < evt.delay + 200); 191 | assert(evt.elapsed > evt.delay); 192 | }); 193 | assert.equal(numRcvd, evts.length); 194 | done(err); 195 | }); 196 | }); 197 | 198 | it('Post and receive many events', function (done) { 199 | var evts = [ 200 | { msg: { msg: 'msg0' }, delay: 50 }, 201 | { msg: { msg: 'msg1' }, delay: 50 }, 202 | { msg: { msg: 'msg2' }, delay: 50 }, 203 | { msg: { msg: 'msg3' }, delay: 50 }, 204 | { msg: { msg: 'msg4' }, delay: 50 }, 205 | { msg: { msg: 'msg5' }, delay: 50 }, 206 | { msg: { msg: 'msg6' }, delay: 50 }, 207 | { msg: { msg: 'msg7' }, delay: 50 }, 208 | { msg: { msg: 'msg8' }, delay: 50 }, 209 | { msg: { msg: 'msg9' }, delay: 50 } 210 | ]; 211 | var numRcvd = 0; 212 | dt.maxEvents = 5; 213 | async.series([ 214 | function (next) { 215 | dt.join(function () { 216 | next(); 217 | }); 218 | }, 219 | function (next) { 220 | var since = Date.now(); 221 | evts.forEach(function (evt) { 222 | dt.post(evt.msg, evt.delay, function (err, evId) { 223 | assert.ifError(err); 224 | evt.id = evId; 225 | evt.postDelay = Date.now() - since; 226 | evt.posted = true; 227 | }); 228 | }); 229 | dt.on('event', function (ev) { 230 | var elapsed = Date.now() - since; 231 | evts.forEach(function (evt) { 232 | if (evt.msg.msg === ev.msg) { 233 | numRcvd++; 234 | evt.elapsed = elapsed; 235 | evt.rcvd = ev; 236 | evt.order = numRcvd; 237 | } 238 | }); 239 | }); 240 | setTimeout(next, 100); 241 | }, 242 | function (next) { 243 | dt.leave(function () { 244 | next(); 245 | }); 246 | } 247 | ], function (err, results) { 248 | void(results); 249 | evts.forEach(function (evt) { 250 | assert.ok(evt.posted); 251 | assert.deepEqual(evt.msg.msg, evt.rcvd.msg); 252 | assert(evt.elapsed < evt.delay + 200); 253 | assert(evt.elapsed > evt.delay); 254 | }); 255 | assert.equal(numRcvd, evts.length); 256 | done(err); 257 | }); 258 | }); 259 | 260 | it('Post then cancel', function (done) { 261 | var evt = { msg: 'hello' }; 262 | var delay = 500; 263 | async.series([ 264 | function (next) { 265 | dt.join(function () { 266 | next(); 267 | }); 268 | }, 269 | function (next) { 270 | var numEvents = 0; 271 | dt.post(evt, delay, function (err, evId) { 272 | assert.ifError(err); 273 | dt.cancel(evId, function (err) { 274 | assert.ifError(err); 275 | }); 276 | }); 277 | dt.on('event', function (ev) { 278 | numEvents++; 279 | assert.deepEqual(ev, evt); 280 | }); 281 | setTimeout(function () { 282 | assert.equal(numEvents, 0); 283 | next(); 284 | }, 1000); 285 | }, 286 | function (next) { 287 | dt.leave(function () { 288 | next(); 289 | }); 290 | } 291 | ], function (err, results) { 292 | void(results); 293 | done(err); 294 | }); 295 | }); 296 | 297 | it('Post with confirmation', function (done) { 298 | var evt = { id: 'myEvent', maxRetries: 1, msg: 'hello' }; 299 | var delay = 500; 300 | async.series([ 301 | function (next) { 302 | dt.join(function () { 303 | next(); 304 | }); 305 | }, 306 | function (next) { 307 | var since = Date.now(); 308 | var numEvents = 0; 309 | dt.post(evt, delay, function (err, evId) { 310 | assert.ifError(err); 311 | assert.equal(evId, evt.id); 312 | }); 313 | dt.on('event', function (ev) { 314 | var elapsed = Date.now() - since; 315 | numEvents++; 316 | assert.equal(numEvents, 1); 317 | assert.strictEqual(ev.id, evt.id); 318 | assert.strictEqual(ev.msg, evt.msg); 319 | assert(elapsed < delay * 1.1); 320 | assert(elapsed > delay * 0.9); 321 | 322 | dt.confirm(ev.id, next); 323 | }); 324 | }, 325 | function (next) { 326 | setTimeout(next, 3000); // run for a while 327 | }, 328 | function (next) { 329 | dt.leave(function () { 330 | next(); 331 | }); 332 | } 333 | ], function (err, results) { 334 | void(results); 335 | done(err); 336 | }); 337 | }); 338 | 339 | it('Post, ignore 1st & 2nd events, then confirm the 3rd', function (done) { 340 | var evt = { id: 'myEvent', maxRetries: 2, msg: 'hello' }; 341 | var delay = 500; 342 | async.series([ 343 | function (next) { 344 | dt.join(function () { 345 | next(); 346 | }); 347 | }, 348 | function (next) { 349 | var since = Date.now(); 350 | var numEvents = 0; 351 | dt.post(evt, delay, function (err, evId) { 352 | assert.ifError(err); 353 | assert.equal(evId, evt.id); 354 | }); 355 | dt.on('event', function (ev) { 356 | var elapsed = Date.now() - since; 357 | numEvents++; 358 | assert.strictEqual(ev.id, evt.id); 359 | assert.strictEqual(ev.msg, evt.msg); 360 | if (numEvents === 1) { 361 | assert(elapsed < delay * 1.1); 362 | assert(elapsed > delay * 0.9); 363 | } else if (numEvents === 2) { 364 | assert.equal(ev._numRetries, 1); 365 | } else if (numEvents === 3) { 366 | assert.equal(ev._numRetries, 2); 367 | dt.confirm(ev.id, next); 368 | } else { 369 | assert(false, 'unexpected event'); 370 | } 371 | }); 372 | }, 373 | function (next) { 374 | setTimeout(next, 2000); // run for a while 375 | }, 376 | function (next) { 377 | dt.leave(function () { 378 | next(); 379 | }); 380 | } 381 | ], function (err, results) { 382 | void(results); 383 | done(err); 384 | }); 385 | }); 386 | 387 | it('Post then peek the event', function (done) { 388 | var evt = { id: 'myEvent', msg: 'hello' }; 389 | var delay = 2000; 390 | async.series([ 391 | function (next) { 392 | dt.join(function () { 393 | next(); 394 | }); 395 | }, 396 | function (next) { 397 | dt.post(evt, delay, function (err, evId) { 398 | assert.ifError(err); 399 | assert.equal(evId, evt.id); 400 | 401 | // peek the event right away 402 | dt.peek(evId, function (err, results) { 403 | assert.ifError(err); 404 | assert.ok(Array.isArray(results)); 405 | assert.equal(results.length, 2); 406 | assert.equal(typeof results[0], 'number'); 407 | assert.ok(results[0] < delay*1.1); 408 | assert.ok(results[0] > delay*0.9); 409 | assert.strictEqual(results[1].id, evt.id); 410 | assert.strictEqual(results[1].msg, evt.msg); 411 | next(); 412 | }); 413 | }); 414 | }, 415 | function (next) { 416 | dt.leave(function () { 417 | next(); 418 | }); 419 | } 420 | ], function (err, results) { 421 | void(results); 422 | done(err); 423 | }); 424 | }); 425 | 426 | it('Peek event that does not exist', function (done) { 427 | async.series([ 428 | function (next) { 429 | dt.peek('notExist', function (err, results) { 430 | assert.ifError(err); 431 | assert.ok(Array.isArray(results)); 432 | assert.equal(results.length, 2); 433 | assert.strictEqual(results[0], null); 434 | assert.strictEqual(results[1], null); 435 | next(); 436 | }); 437 | }, 438 | function (next) { 439 | dt.leave(function () { 440 | next(); 441 | }); 442 | } 443 | ], function (err, results) { 444 | void(results); 445 | done(err); 446 | }); 447 | }); 448 | 449 | it('Post then change delay', function (done) { 450 | var evt = { id: 'myEvent', msg: 'hello' }; 451 | var delay = 500; 452 | async.series([ 453 | function (next) { 454 | dt.join(function () { 455 | next(); 456 | }); 457 | }, 458 | function (next) { 459 | var since = Date.now(); 460 | var numEvents = 0; 461 | dt.post(evt, 5000, function (err, evId) { 462 | assert.ifError(err); 463 | assert.equal(evId, evt.id); 464 | 465 | // change delay right away 466 | dt.changeDelay(evId, delay, function (err, ok) { 467 | assert.ifError(err); 468 | assert.strictEqual(ok, 1); 469 | }); 470 | }); 471 | dt.on('event', function (ev) { 472 | var elapsed = Date.now() - since; 473 | numEvents++; 474 | assert.equal(numEvents, 1); 475 | assert.strictEqual(ev.id, evt.id); 476 | assert.strictEqual(ev.msg, evt.msg); 477 | assert(elapsed < delay * 1.1); 478 | assert(elapsed > delay * 0.9); 479 | next(); 480 | }); 481 | }, 482 | function (next) { 483 | setTimeout(next, 2000); // run for a while 484 | }, 485 | function (next) { 486 | dt.leave(function () { 487 | next(); 488 | }); 489 | } 490 | ], function (err, results) { 491 | void(results); 492 | done(err); 493 | }); 494 | }); 495 | 496 | describe('#upcoming', function () { 497 | beforeEach(function (done) { 498 | dt.join(done); 499 | }); 500 | 501 | function post3events(ev1, ev2, ev3, ids, cb) { 502 | async.series([ 503 | function (next) { 504 | debug('post 1..'); 505 | dt.post(ev1, 1000, function (err, evId) { 506 | assert.ifError(err); 507 | ids.push(evId); 508 | next(); 509 | }); 510 | }, 511 | function (next) { 512 | debug('post 2..'); 513 | dt.post(ev2, 2000, function (err, evId) { 514 | assert.ifError(err); 515 | ids.push(evId); 516 | next(); 517 | }); 518 | }, 519 | function (next) { 520 | debug('post 3..'); 521 | dt.post(ev3, 3000, function (err, evId) { 522 | assert.ifError(err); 523 | ids.push(evId); 524 | next(); 525 | }); 526 | } 527 | ], cb); 528 | } 529 | 530 | it('upcoming() with no event', function (done) { 531 | var ids = []; 532 | var events; 533 | async.series([ 534 | function (next) { 535 | debug('upcoming..'); 536 | dt.upcoming(function (err, _events) { 537 | assert.ifError(err); 538 | events = _events; 539 | next(); 540 | }); 541 | }, 542 | function (next) { 543 | dt.leave(function () { 544 | next(); 545 | }); 546 | } 547 | ], function (err) { 548 | debug('events=' + JSON.stringify(events, null, 2)); 549 | assert.ifError(err); 550 | assert.strictEqual(ids.length, 0); 551 | assert.strictEqual(Object.keys(events).length, 0); 552 | done(); 553 | }); 554 | }); 555 | 556 | it('upcoming() to retrieve all events', function (done) { 557 | var evt = { msg: 'hello' }; 558 | var ids = []; 559 | var events; 560 | async.series([ 561 | function (next) { 562 | debug('posting..'); 563 | post3events(evt, evt, evt, ids, next); 564 | }, 565 | function (next) { 566 | debug('upcoming..'); 567 | dt.upcoming(function (err, _events) { 568 | assert.ifError(err); 569 | events = _events; 570 | next(); 571 | }); 572 | }, 573 | function (next) { 574 | dt.leave(function () { 575 | next(); 576 | }); 577 | } 578 | ], function (err) { 579 | debug('events=' + JSON.stringify(events, null, 2)); 580 | assert.ifError(err); 581 | assert.strictEqual(ids.length, 3); 582 | assert.strictEqual(Object.keys(events).length, 3); 583 | done(); 584 | }); 585 | }); 586 | 587 | it('upcoming() to retrieve just 2 events using limit', function (done) { 588 | var evt = { msg: 'hello' }; 589 | var ids = []; 590 | var events; 591 | async.series([ 592 | function (next) { 593 | debug('posting..'); 594 | post3events(evt, evt, evt, ids, next); 595 | }, 596 | function (next) { 597 | debug('upcoming..'); 598 | dt.upcoming({ limit: 2 }, function (err, _events) { 599 | assert.ifError(err); 600 | events = _events; 601 | next(); 602 | }); 603 | }, 604 | function (next) { 605 | dt.leave(function () { 606 | next(); 607 | }); 608 | } 609 | ], function (err) { 610 | debug('events=' + JSON.stringify(events, null, 2)); 611 | assert.ifError(err); 612 | assert.strictEqual(ids.length, 3); 613 | assert.strictEqual(Object.keys(events).length, 2); 614 | done(); 615 | }); 616 | }); 617 | 618 | it('upcoming() to retrieve just 1 event using duration', function (done) { 619 | var evt = { msg: 'hello' }; 620 | var ids = []; 621 | var events; 622 | async.series([ 623 | function (next) { 624 | debug('posting..'); 625 | post3events(evt, evt, evt, ids, next); 626 | }, 627 | function (next) { 628 | debug('upcoming..'); 629 | dt.upcoming({ duration: 1100 }, function (err, _events) { 630 | assert.ifError(err); 631 | events = _events; 632 | next(); 633 | }); 634 | }, 635 | function (next) { 636 | dt.leave(function () { 637 | next(); 638 | }); 639 | } 640 | ], function (err) { 641 | debug('events=' + JSON.stringify(events, null, 2)); 642 | assert.ifError(err); 643 | assert.strictEqual(ids.length, 3); 644 | assert.strictEqual(Object.keys(events).length, 1); 645 | done(); 646 | }); 647 | }); 648 | 649 | it('upcoming() to retrieve just 2 event using offset', function (done) { 650 | var evt = { msg: 'hello' }; 651 | var ids = []; 652 | var events; 653 | async.series([ 654 | function (next) { 655 | debug('posting..'); 656 | post3events(evt, evt, evt, ids, next); 657 | }, 658 | function (next) { 659 | debug('upcoming..'); 660 | dt.upcoming({ offset: 1100 }, function (err, _events) { 661 | assert.ifError(err); 662 | events = _events; 663 | next(); 664 | }); 665 | }, 666 | function (next) { 667 | dt.leave(function () { 668 | next(); 669 | }); 670 | } 671 | ], function (err) { 672 | debug('events=' + JSON.stringify(events, null, 2)); 673 | assert.ifError(err); 674 | assert.strictEqual(ids.length, 3); 675 | assert.strictEqual(Object.keys(events).length, 2); 676 | done(); 677 | }); 678 | }); 679 | 680 | it('upcoming() to retrieve just 1 event using both offset and duration', function (done) { 681 | var evt = { msg: 'hello' }; 682 | var ids = []; 683 | var events; 684 | async.series([ 685 | function (next) { 686 | debug('posting..'); 687 | post3events(evt, evt, evt, ids, next); 688 | }, 689 | function (next) { 690 | debug('upcoming..'); 691 | dt.upcoming({ offset: 1100, duration: 1000 }, function (err, _events) { 692 | assert.ifError(err); 693 | events = _events; 694 | next(); 695 | }); 696 | }, 697 | function (next) { 698 | dt.leave(function () { 699 | next(); 700 | }); 701 | } 702 | ], function (err) { 703 | debug('events=' + JSON.stringify(events, null, 2)); 704 | assert.ifError(err); 705 | assert.strictEqual(ids.length, 3); 706 | assert.strictEqual(Object.keys(events).length, 1); 707 | done(); 708 | }); 709 | }); 710 | }); 711 | }); 712 | --------------------------------------------------------------------------------