├── .travis.yml ├── README.md ├── lib ├── cluster.js └── redis.js ├── package.js ├── smart.json └── test ├── publish.js ├── redis.js ├── streams.js └── subscribe.js /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.8" 4 | - "0.10" 5 | before_install: 6 | - "curl -L http://git.io/ejPSng | /bin/sh" 7 | services: redis 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # This is an archived project 2 | 3 | ## Are you looking for our newest [Meteor Cluster](https://github.com/meteorhacks/cluster) project? 4 | 5 | Meteor Cluster is now retired & Meteor's Collection implementation implementation with [Oplog](https://github.com/meteor/meteor/wiki/Oplog-Observe-Driver) support can be used to run a cluster of Meteor nodes. 6 | 7 | meteor-cluster [![Build Status](https://travis-ci.org/arunoda/meteor-cluster.png?branch=master)](https://travis-ci.org/arunoda/meteor-cluster) 8 | ============== 9 | 10 | ### Smarter way to run cluster of meteor nodes 11 | 12 | #### See: [Meteor Cluster - Introduction & How it works](http://meteorhacks.com/meteor-cluster-introduction-and-how-it-works.html) article 13 | 14 | Meteor is not a toy anymore. People keen to build enterprise apps on top of the meteor. So people need to run cluster of meteor nodes for several reasons. 15 | 16 | But when running cluster of nodes, meteor is not realtime anymore(between nodes). It get synced but takes few seconds. 17 | 18 | **Here comes the solution - `meteor-cluster`** 19 | 20 | [![Meteor Cluster in Action](http://i.imgur.com/lidwQaW.png)](http://www.youtube.com/watch?v=12NkUJEdFCw&feature=youtu.be) 21 | 22 | ### Installation 23 | 24 | just run `mrt add cluster` 25 | 26 | ### Redis 27 | 28 | `meteor-cluster` uses redis as the communicate channel between nodes. It uses pub/sub functionality of redis. 29 | So you need to have redis-server running. 30 | 31 | If you are new to redis, [read this guide](http://redis.io/topics/quickstart) 32 | 33 | ### Configurations 34 | 35 | `meteor-cluster` needs to know 36 | 37 | * how to connect with redis 38 | * what collections it needs to sync 39 | 40 | It just a two lines of configuration. Add following inside you server code. 41 | 42 | ~~~js 43 | Meteor.startup(function() { 44 | Meteor.Cluster.init(); //assumes you are running redis-server locally 45 | Meteor.Cluster.sync(Posts, Notifications, Comments); //these are my collections 46 | }); 47 | ~~~ 48 | 49 | ### How to scale 50 | 51 | Just fire up new nodes and it just works. 52 | 53 | ### Tests 54 | 55 | * start `redis-server` before testing 56 | * `mrt test-packages ./` 57 | 58 | ### API 59 | 60 | #### Meteor.Cluster.init(redisConfig) 61 | 62 | Initialize and connect to redis. 63 | 64 | * redisConfig - null or `{port: 6337, host: "localhost", auth: null, db: null}` 65 | * alternatively you can pass redis config via `CLUSTER_URL` env variable 66 | * CLUSTER_URL formart: `redis://redis:@:` 67 | 68 | #### Meteor.Cluster.sync(collections...) 69 | 70 | Sync given set of collection between nodes 71 | 72 | * collections... - Collections defined as list of arguments 73 | -------------------------------------------------------------------------------- /lib/cluster.js: -------------------------------------------------------------------------------- 1 | function Cluster() { 2 | 3 | var serverId = Random.id(); 4 | var redisPublishClient; 5 | var redisSubscribeClient; 6 | 7 | var collections = {}; 8 | var streams = {}; 9 | 10 | this.sync = function() { 11 | _.each(arguments, function(arg) { 12 | if(arg.constructor == Meteor.Collection) { 13 | collections[arg._name] = arg; 14 | watchCollection(arg); 15 | } else if(arg.constructor == Meteor.Stream) { 16 | streams[arg.name] = arg; 17 | watchStream(arg); 18 | } 19 | }); 20 | } 21 | 22 | this.init = function(redisConfig) { 23 | redisConfig = redisConfig || Meteor._parseRedisEnvUrl() || {}; 24 | 25 | redisPublishClient = Meteor._createRedisClient(redisConfig); 26 | redisSubscribeClient = Meteor._createRedisClient(redisConfig); 27 | 28 | redisSubscribeClient.once('subscribe', this.onsubscribe); 29 | 30 | redisSubscribeClient.on('message', function(channel, message) { 31 | var parsedMessage = JSON.parse(message); 32 | if(parsedMessage[0] != serverId) { 33 | if(channel == 'collections') { 34 | onCollectionMessage(parsedMessage[1], parsedMessage[2], parsedMessage[3]); 35 | } else if(channel == 'streams') { 36 | onStreamMessage(parsedMessage[1], parsedMessage[2], parsedMessage[3], parsedMessage[4]); 37 | } 38 | } 39 | }); 40 | 41 | redisSubscribeClient.subscribe('collections'); 42 | redisSubscribeClient.subscribe('streams'); 43 | }; 44 | 45 | this.close = function() { 46 | redisSubscribeClient.unsubscribe('collections'); 47 | redisSubscribeClient.unsubscribe('streams'); 48 | redisSubscribeClient.end(); 49 | redisPublishClient.end(); 50 | }; 51 | 52 | this.onsubscribe = function() {}; 53 | 54 | function watchStream(stream) { 55 | stream.firehose = function(args, subscriptionId, userId) { 56 | var payload = [serverId, stream.name, args, subscriptionId, userId]; 57 | var payloadJSON = JSON.stringify(payload); 58 | redisPublishClient.publish('streams', payloadJSON); 59 | } 60 | } 61 | 62 | function watchCollection(collection) { 63 | var methods = ['insert', 'update', 'remove']; 64 | methods.forEach(function(method) { 65 | var original = collection._collection[method]; 66 | collection._collection[method] = function() { 67 | //find a better way to do this rather than ._dontPublish 68 | //delete is expensive 69 | var dontPublish = arguments[0]._dontPublish; 70 | delete arguments[0]._dontPublish; 71 | original.apply(collection, arguments); 72 | 73 | if(!dontPublish) { 74 | publishAction(collection._name, method, arguments); 75 | } 76 | }; 77 | }); 78 | } 79 | 80 | function publishAction(collectionName, method, arguments) { 81 | if(method == 'insert') { 82 | arguments = [{_id: arguments[0]._id}]; 83 | } 84 | onAction(collectionName, method, arguments); 85 | } 86 | 87 | function onAction(collectionName, method, args) { 88 | if(redisPublishClient) { 89 | var sendData = [serverId, collectionName, method, args]; 90 | var sendDataString = JSON.stringify(sendData); 91 | 92 | redisPublishClient.publish('collections', sendDataString); 93 | } 94 | } 95 | 96 | function onCollectionMessage(collectionName, method, args) { 97 | var collection = collections[collectionName]; 98 | var Fiber = Npm.require('fibers'); 99 | 100 | if(collection) { 101 | if(method == 'insert') { 102 | Fiber(function() { 103 | collection.update(args[0]._id, {$set: {}}); 104 | }).run(); 105 | } else if (method == 'update') { 106 | //get this from somewhere else 107 | Fiber(function() { 108 | var docs = collection.find(args[0], {fields: {_id: 1}}); 109 | docs.forEach(function(doc) { 110 | var query = {_id: doc._id, _dontPublish: true}; 111 | collection.update(query, {$set: {}}); 112 | }); 113 | }).run(); 114 | } else if (method == 'remove') { 115 | var query = (typeof(args[0]) == 'object')? args[0]: { _id: args[0]}; 116 | query._dontPublish = true; 117 | 118 | Fiber(function() { 119 | collection.remove(query); 120 | }).run(); 121 | } 122 | } 123 | } 124 | 125 | function onStreamMessage(streamName, args, subscriptionId, userId) { 126 | var stream = streams[streamName]; 127 | if(stream) { 128 | stream.emitToSubscriptions(args, subscriptionId, userId); 129 | } 130 | } 131 | } 132 | 133 | Meteor.Cluster = new Cluster(); -------------------------------------------------------------------------------- /lib/redis.js: -------------------------------------------------------------------------------- 1 | var url = Npm.require('url'); 2 | 3 | Meteor._createRedisClient = function _createRedisClient(conf) { 4 | conf = conf || {}; 5 | 6 | console.info('connecting to redis'); 7 | 8 | var redis = Npm.require('redis'); 9 | var client = redis.createClient(conf.port, conf.host); 10 | 11 | if(conf.auth) { 12 | client.auth(conf.auth, afterAuthenticated); 13 | } 14 | 15 | function afterAuthenticated(err) { 16 | if(err) { 17 | throw err; 18 | } 19 | } 20 | 21 | client.on('error', function(err) { 22 | console.error('connection to redis disconnected', {error: err.toString()}) 23 | }); 24 | 25 | client.on('connect', function() { 26 | console.info('connected to redis: ' + Meteor._redisConfToString(conf)); 27 | }); 28 | 29 | client.on('reconnecting', function() { 30 | console.info('re-connecting to redis' + Meteor._redisConfToString(conf)); 31 | }); 32 | 33 | return client; 34 | }; 35 | 36 | Meteor._parseRedisEnvUrl = function _parseRedisEnvUrl() { 37 | if(process.env.CLUSTER_URL) { 38 | var parsedUrl = url.parse(process.env.CLUSTER_URL); 39 | if(parsedUrl.protocol == 'redis:' && parsedUrl.hostname && parsedUrl.port) { 40 | var connObj = { 41 | host: parsedUrl.hostname, 42 | port: parseInt(parsedUrl.port), 43 | }; 44 | 45 | if(parsedUrl.auth) { 46 | connObj.auth = parsedUrl.auth.split(':')[1]; 47 | } 48 | 49 | return connObj; 50 | } else { 51 | throw new Error( 52 | 'CLUSTER_URL must contain following url format\n\tredis://redis:@:' 53 | ); 54 | } 55 | } else { 56 | return null; 57 | } 58 | } 59 | 60 | Meteor._redisConfToString = function _redisConfToString(conf) { 61 | var str = (conf.host || "localhost") + ":" + (conf.port || 6379); 62 | if(conf.auth) { 63 | str = "redis:" + conf.auth + "@" + str; 64 | } 65 | return str; 66 | } -------------------------------------------------------------------------------- /package.js: -------------------------------------------------------------------------------- 1 | Package.describe({ 2 | summary: "Smarter way to run cluster of meteor nodes" 3 | }); 4 | 5 | Npm.depends({"redis" : "0.8.3"}); 6 | 7 | Package.on_use(function (api, where) { 8 | api.use(['mongo-livedata', 'random'], 'server'); 9 | api.add_files(['lib/redis.js', 'lib/cluster.js'], 'server'); 10 | }); 11 | 12 | Package.on_test(function (api) { 13 | api.use(['mongo-livedata', 'random', 'tinytest'], 'server'); 14 | api.add_files(['lib/redis.js', 'lib/cluster.js'], 'server'); 15 | api.add_files([ 16 | 'test/redis.js', 17 | 'test/publish.js', 18 | 'test/subscribe.js', 19 | 'test/streams.js' 20 | ], 'server'); 21 | }); 22 | -------------------------------------------------------------------------------- /smart.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cluster", 3 | "description": "Smarter way to run cluster of meteor nodes", 4 | "homepage": "https://github.com/arunoda/meteor-cluster", 5 | "author": "Arunoda Susiripala ", 6 | "version": "0.1.7", 7 | "git": "https://github.com/arunoda/meteor-cluster.git" 8 | } 9 | -------------------------------------------------------------------------------- /test/publish.js: -------------------------------------------------------------------------------- 1 | var Future = Npm.require('fibers/future'); 2 | 3 | Tinytest.add('insert and publish', function(test) { 4 | var f = new Future(); 5 | var cluster = new Meteor.Cluster.constructor(); 6 | var coll = new Meteor.Collection(Random.id()); 7 | 8 | var redisClient = Meteor._createRedisClient(); 9 | redisClient.on('subscribe', function() { 10 | f.return(); 11 | }) 12 | redisClient.subscribe('collections'); 13 | f.wait(); 14 | 15 | cluster.init(); 16 | cluster.sync(coll); 17 | 18 | var args; 19 | redisClient.on('message', function(channel, message) { 20 | args = JSON.parse(message); 21 | f.return(); 22 | }) 23 | var doc = {_id: "abc", name: "arunoda"}; 24 | coll.insert(doc); 25 | f = new Future(); 26 | f.wait(); 27 | 28 | test.equal(args[1], coll._name); 29 | test.equal(args[2], 'insert'); 30 | test.equal(args[3][0]._id, doc._id); 31 | test.equal(_.keys(args[3][0]), ['_id']); 32 | 33 | redisClient.end(); 34 | cluster.close(); 35 | }); 36 | 37 | Tinytest.add('update and publish', function(test) { 38 | var f = new Future(); 39 | var cluster = new Meteor.Cluster.constructor(); 40 | var coll = new Meteor.Collection(Random.id()); 41 | 42 | var redisClient = Meteor._createRedisClient(); 43 | redisClient.on('subscribe', function() { 44 | f.return(); 45 | }) 46 | redisClient.subscribe('collections'); 47 | f.wait(); 48 | 49 | cluster.init(); 50 | cluster.sync(coll); 51 | 52 | var query = {name: 'hello'}; 53 | var set = {$set: {room: 23}}; 54 | var args; 55 | redisClient.on('message', function(channel, message) { 56 | args = JSON.parse(message); 57 | f.return(); 58 | }) 59 | coll.update(query, set); 60 | f = new Future(); 61 | f.wait(); 62 | 63 | test.equal(args[1], coll._name); 64 | test.equal(args[2], 'update'); 65 | test.equal(args[3][0].name, query.name); 66 | test.equal(args[3][1]['$set'].room, set['$set'].room); 67 | 68 | redisClient.end(); 69 | cluster.close(); 70 | }); 71 | 72 | Tinytest.add('remove and publish', function(test) { 73 | var f = new Future(); 74 | var cluster = new Meteor.Cluster.constructor(); 75 | var coll = new Meteor.Collection(Random.id()); 76 | 77 | var redisClient = Meteor._createRedisClient(); 78 | redisClient.on('subscribe', function() { 79 | f.return(); 80 | }) 81 | redisClient.subscribe('collections'); 82 | f.wait(); 83 | 84 | cluster.init(); 85 | cluster.sync(coll); 86 | 87 | var query = {name: 'hello'}; 88 | var args; 89 | redisClient.on('message', function(channel, message) { 90 | args = JSON.parse(message); 91 | f.return(); 92 | }) 93 | coll.remove(query); 94 | f = new Future(); 95 | f.wait(); 96 | 97 | test.equal(args[1], coll._name); 98 | test.equal(args[2], 'remove'); 99 | test.equal(args[3][0].name, query.name); 100 | 101 | redisClient.end(); 102 | cluster.close(); 103 | }); -------------------------------------------------------------------------------- /test/redis.js: -------------------------------------------------------------------------------- 1 | var Future = Npm.require('fibers/future'); 2 | 3 | Tinytest.add('without env variable CLUSTER_URL', function(test) { 4 | var connObj = Meteor._parseRedisEnvUrl(); 5 | test.equal(connObj, null); 6 | }); 7 | 8 | Tinytest.add('invalid env variable CLUSTER_URL', function(test) { 9 | process.env.CLUSTER_URL = "invalid-url"; 10 | try { 11 | var connObj = Meteor._parseRedisEnvUrl(); 12 | test.equal(connObj, null); 13 | test.ok(false); 14 | } catch(ex) { 15 | delete process.env.CLUSTER_URL; 16 | } 17 | }); 18 | 19 | Tinytest.add('parse correct redis url', function(test) { 20 | process.env.CLUSTER_URL = "redis://redis:pass@hostname:9389"; 21 | var connObj = Meteor._parseRedisEnvUrl(); 22 | test.equal(connObj.host, "hostname"); 23 | test.equal(connObj.port, 9389); 24 | test.equal(connObj.auth, "pass"); 25 | delete process.env.CLUSTER_URL; 26 | }); 27 | 28 | Tinytest.add('redisConfToString with empty object', function(test) { 29 | var conn = {}; 30 | var str = Meteor._redisConfToString(conn); 31 | test.equal(str, 'localhost:6379'); 32 | }); 33 | 34 | Tinytest.add('redisConfToString without auth', function(test) { 35 | var conn = {host: 'localhost', port: 8900}; 36 | var str = Meteor._redisConfToString(conn); 37 | test.equal(str, 'localhost:8900'); 38 | }); 39 | 40 | Tinytest.add('redisConfToString with auth', function(test) { 41 | var conn = {host: 'localhost', port: 8900, auth: 'abc'}; 42 | var str = Meteor._redisConfToString(conn); 43 | test.equal(str, 'redis:abc@localhost:8900'); 44 | }); -------------------------------------------------------------------------------- /test/streams.js: -------------------------------------------------------------------------------- 1 | var Future = Npm.require('fibers/future'); 2 | var Fibers = Npm.require('fibers'); 3 | 4 | function Stream(name) { 5 | this.name = name; 6 | this.emitToSubscriptions; 7 | } 8 | 9 | function runInFiber(callback) { 10 | Fibers(callback).run(); 11 | } 12 | 13 | Tinytest.addAsync('stream to redis', function(test, done) { 14 | var f = new Future(); 15 | var cluster = new Meteor.Cluster.constructor(); 16 | Meteor.Stream = Stream; 17 | var stream = new Meteor.Stream('hello'); 18 | 19 | var args = ['env', {a: 10}]; 20 | var subscriptionId = 'subscription-1'; 21 | var userId = 'user-1'; 22 | 23 | var redisClient = Meteor._createRedisClient(); 24 | redisClient.on('subscribe', function() { 25 | f.return(); 26 | }) 27 | redisClient.subscribe('streams'); 28 | f.wait(); 29 | 30 | cluster.init(); 31 | cluster.sync(stream); 32 | 33 | redisClient.on('message', function(channel, message) { 34 | runInFiber(function() { 35 | var parseMessage = JSON.parse(message); 36 | test.equal(channel, 'streams'); 37 | test.equal(parseMessage[1], stream.name); 38 | test.equal(parseMessage[2][0], args[0]); 39 | test.equal(parseMessage[3], subscriptionId); 40 | test.equal(parseMessage[4], userId); 41 | 42 | redisClient.end(); 43 | cluster.close(); 44 | done(); 45 | }); 46 | }); 47 | 48 | stream.firehose(args, subscriptionId, userId); 49 | }); 50 | 51 | Tinytest.addAsync('redis to stream', function(test, done) { 52 | var f = new Future(); 53 | var cluster = new Meteor.Cluster.constructor(); 54 | Meteor.Stream = Stream; 55 | var stream = new Meteor.Stream('hello'); 56 | 57 | var args = ['env', {a: 10}]; 58 | var subscriptionId = 'subscription-1'; 59 | var userId = 'user-1'; 60 | 61 | var redisClient = Meteor._createRedisClient(); 62 | cluster.init(); 63 | cluster.sync(stream); 64 | 65 | stream.emitToSubscriptions = function(_args, _subscriptionId, _userId) { 66 | runInFiber(function() { 67 | test.equal(_args[0], 'env'); 68 | test.equal(_subscriptionId, subscriptionId); 69 | test.equal(_userId, userId); 70 | 71 | redisClient.end(); 72 | cluster.close(); 73 | done(); 74 | }); 75 | }; 76 | 77 | var payload = ['fakeServerId', stream.name, args, subscriptionId, userId]; 78 | setTimeout(function() { 79 | redisClient.publish('streams', JSON.stringify(payload)); 80 | }, 50); 81 | }); 82 | -------------------------------------------------------------------------------- /test/subscribe.js: -------------------------------------------------------------------------------- 1 | var Future = Npm.require('fibers/future'); 2 | 3 | Tinytest.add('update and subscribe', function(test) { 4 | var f = new Future(); 5 | var cluster = new Meteor.Cluster.constructor(); 6 | var pubClient = Meteor._createRedisClient(); 7 | 8 | var collName = Random.id(); 9 | var syncColl = new Meteor.Collection(collName); 10 | 11 | var orginalUpdate = syncColl.update; 12 | var updatingIds = []; 13 | syncColl.update = function(query, updateString) { 14 | updatingIds.push(query._id); 15 | orginalUpdate.call(syncColl, query, updateString); 16 | if(updatingIds.length == 2) { 17 | f.return(); 18 | } 19 | }; 20 | 21 | syncColl.insert({_id: "1", room: 10}); 22 | syncColl.insert({_id: "2", room: 10}); 23 | 24 | cluster.onsubscribe = function() { 25 | f.return(); 26 | }; 27 | cluster.init(); 28 | cluster.sync(syncColl); 29 | f.wait(); 30 | 31 | var json = [ 32 | "server-id", 33 | collName, 34 | 'update', 35 | [{room: 10}] 36 | ]; 37 | 38 | pubClient.publish('collections', JSON.stringify(json)); 39 | f = new Future(); 40 | f.wait(); 41 | test.equal(updatingIds[0], "1"); 42 | test.equal(updatingIds[1], "2"); 43 | 44 | cluster.close(); 45 | pubClient.end(); 46 | }); --------------------------------------------------------------------------------