UNPKG

atomize-server

Version:

Node server library for AtomizeJS: JavaScript DSTM

664 lines (578 loc) 25.8 kB
/*global require, exports */ /*jslint devel: true */ /* *************************************************************************** # AtomizeJS Node Server Distributed objects are represented by TVar objects. When an object is created, its value is passed directly into the TVar constructor which takes a copy of it, and sets up version tracking and observer patterns. TVars have global IDs, which are not communicated to clients, and client-local IDs, which are. Thus each client maintains a mapping from client-local IDs to global IDs, and vice versa. Thus you can think of global IDs as being abstract physical addresses, and client-local IDs as being abstract virtual addresses, if you want to think in OS terms. Updates to an object's fields appear as property descriptors within the transaction log, received from clients. If the descriptor object has a 'tvar' field, then the descriptor's 'value' field is set to be the corresponding global TVar ID. Thus the TVar's 'raw' object will contain fields with abstract pointers of the form {'tvar': id}. If the field's value is a primitive, then the value is applied directly to the TVar's 'raw' object, modulo some rubbish for coping with the fact that for some daft reason you can't do Object.defineProperty on an Array's 'length' field. Fields which have been deleted by clients are indicated as such in the transaction log received from the client and get directly deleted from the TVar's 'raw' object. When updates are sent down to clients, we send down the entire object, with each of its existing fields as a property descriptor. The client is responsible for updating its own representation of the object accordingly - in particular identifying which, if any fields, have been deleted. Again, if a field's value is an abstract pointer of the form {'tvar': id} then the descriptor passed to the client will have no 'value' field, but instead a 'tvar' field, the value of which is the client-local ID. * ***************************************************************************/ (function () { 'use strict'; var atomize = require('atomize-client'), cereal = require('cereal'), sockjs = require('sockjs'), events = require('events'), sockjs_opts = {sockjs_url: "http://cdn.sockjs.org/sockjs-0.3.min.js", log: function () {}}, globalTVarCount = 0, globalTVars = {}, globalLocal = {}, create, util, rootTVar; util = (function () { return { isPrimitive: function (obj) { return obj !== Object(obj); }, hasOwnProp: ({}).hasOwnProperty, shallowCopy: function (src, dest) { var keys = Object.keys(src), i; for (i = 0; i < keys.length; i += 1) { dest[keys[i]] = src[keys[i]]; } }, liftFunctions: function (src, dest, fields) { var i, field; for (i = 0; i < fields.length; i += 1) { field = fields[i]; dest[field] = src[field].bind(src); } } }; }()); function TVar(id, isArray, raw) { this.raw = isArray ? [] : {}; util.shallowCopy(raw, this.raw); this.isArray = isArray; this.id = id; this.version = 0; this.observers = []; this.lastTxnSet = {dependencies: this.emptyDependencies}; this.firstTxnSet = this.lastTxnSet; this.txnSetLength = 1; } TVar.prototype = { emptyDependencies: {}, txnSetLengthLimit: 64, // MAGIC NUMBER! subscribe: function (fun) { this.observers.push(fun); }, bump: function (future) { // Create a fresh observers array first, otherwise if // firing the observer creates a new subscription, you can // get into a loop! var observers = this.observers, i; this.version += 1; this.observers = []; for (i = 0; i < observers.length; i += 1) { (observers[i])(this, future); } }, appendDependencies: function (dependencies) { var txnSet = { dependencies: dependencies }; this.lastTxnSet.next = txnSet; this.lastTxnSet = txnSet; this.txnSetLength += 1; }, rollUpTxnSets: function () { var txnSet = this.firstTxnSet, dependencies = {}, ids, i, id, nextTxnSet; if (this.txnSetLength > this.txnSetLengthLimit) { // We're going to roll up everything, and then pop it // into the current lastTxnSet dependencies // field. This means that clients which are already // pointing there will not have to repeat work. Note // that because lastTxnSet.dependencies will be shared // across several TVars, we must create a new // lastTxnSet.dependencies which is non-shared, and // then only rewrite our own lastTxnSet.dependencies // field. while (undefined !== txnSet) { ids = Object.keys(txnSet.dependencies); for (i = 0; i < ids.length; i += 1) { id = ids[i]; dependencies[id] = txnSet.dependencies[id]; } // Wipe out the old dependencies to avoid work // later on, and rewrite pointers to enable faster // catchup. txnSet.dependencies = this.emptyDependencies; nextTxnSet = txnSet.next; txnSet.next = this.lastTxnSet; txnSet = nextTxnSet; } delete this.lastTxnSet.next; // remove the loop we just created! this.lastTxnSet.dependencies = dependencies; this.firstTxnSet = this.lastTxnSet; this.txnSetLength = 1; } } }; TVar.create = function (isArray, value, securityProvider) { var globalTVar; globalTVarCount += 1; globalTVar = new TVar(globalTVarCount, isArray, value); globalTVars[globalTVar.id] = globalTVar; return globalTVar; }; function CoalescingObserver() { this.map = new atomize.Map(); this.keys = []; } CoalescingObserver.prototype = { insert: function (key, value) { if (!this.map.has(key)) { this.keys.push(key); } this.map.set(key, value); }, force: function () { var i; for (i = 0; i < this.keys.length; i += 1) { (this.map.get(this.keys[i]))(); } this.keys = []; this.map = new atomize.Map(); } } function Client(connection, serverEventEmitter) { this.emitter = new events.EventEmitter(); util.liftFunctions( this.emitter, this, ['on', 'once', 'removeListener', 'removeAllListeners', 'emit']); this.securityProvider = serverEventEmitter.securityProvider; this.connection = connection; this.id = connection.id; this.minTVarId = 0; this.localGlobal = {}; this.globalLocal = {}; globalLocal[connection.id] = this.globalLocal; this.addToMapping(rootTVar.id, rootTVar.id); connection.on('data', this.data.bind(this)); connection.on('close', this.close.bind(this)); serverEventEmitter.emit('connection', this); } Client.prototype = { isAuthenticated: false, data: function (message) { try { if (this.isAuthenticated) { this.dispatch(message); } else { this.emit('data', message, this); } } catch (err) { this.connection.close(500, "" + err); } }, close: function () { this.emit('close', this); delete globalLocal[this.connection.id]; }, write: function (msg) { this.connection.write(cereal.stringify(msg)); }, read: function (msg) { return cereal.parse(msg); }, dispatch: function (msg) { var txnLog = this.read(msg); switch (txnLog.type) { case "commit": this.commit(txnLog); break; case "retry": this.retry(txnLog); break; default: this.log("Received unexpected message from client:"); this.log(txnLog); } }, log: function () { var args = Array.prototype.slice.call(arguments, 0); args.unshift(this.connection.id + ":"); console.log.apply(console, args); }, addToMapping: function (globalTVarId, localTVarId) { if (undefined === localTVarId) { this.minTVarId -= 1; localTVarId = this.minTVarId; } this.localGlobal[localTVarId] = {id: globalTVarId, version: 0, txnSet: undefined}; this.globalLocal[globalTVarId] = localTVarId; return localTVarId; }, toGlobalTVar: function (localTVarId) { return globalTVars[this.localGlobal[localTVarId].id]; }, toGlobalTVarID: function (localTVarId) { return this.localGlobal[localTVarId].id; }, toLocalTVarID: function (globalTVarId) { return this.globalLocal[globalTVarId]; }, recordTVarVersion: function (localTVarId, globalTVar) { var entry = this.localGlobal[localTVarId]; entry.version = globalTVar.version; entry.txnSet = globalTVar.lastTxnSet; return entry; }, recordTVarUnpopulated: function (localTVarId, globalTVar) { var entry = this.localGlobal[localTVarId]; entry.version = 0; entry.txnSet = globalTVar.lastTxnSet; return entry; }, localTVarVersion: function (localTVarId) { return this.localGlobal[localTVarId].version; }, localTVarTxnSet: function (localTVarId) { // we've already done txnSet. Hence .next. var txnSet = this.localGlobal[localTVarId].txnSet; return undefined === txnSet ? txnSet : txnSet.next; }, create: function (txnLog) { var ids = Object.keys(txnLog.created), ok = true, i, localTVar, globalTVar; for (i = 0; i < ids.length; i += 1) { localTVar = txnLog.created[ids[i]]; globalTVar = TVar.create(localTVar.isArray, localTVar.value); if (this.securityProvider.lifted(this, globalTVar, localTVar.meta)) { this.addToMapping(globalTVar.id, ids[i]); } else { ok = false; } } return ok; }, checkThing: function (thing, updates, action, checkVersion) { var ids = Object.keys(thing), ok = true, i, localTVar, globalTVar; // Do not short circuit this! We want to send as many // updates as possible to the client. for (i = 0; i < ids.length; i += 1) { localTVar = thing[ids[i]]; globalTVar = this.toGlobalTVar(ids[i]); if (this.securityProvider[action](this, globalTVar, localTVar)) { if (checkVersion && localTVar.version !== globalTVar.version) { updates[globalTVar.id] = true; ok = false; } } else { ok = false; } } return ok; }, checkReads: function (txnLog, updates) { return this.checkThing(txnLog.read, updates, 'read', true); }, checkWrites: function (txnLog, updates) { return this.checkThing(txnLog.written, updates, 'written', false); }, bumpCreated: function (txnLog, dependencies) { // Regardless of success of commit or retry, we always // grab creates. If we read from, or wrote to any of the // objs we created, we will record in the txnlog that we // read from or wrote to version 0 of such objects. Thus // we delay the bumping to version 1 until after the // read/write checks, and we then update ourself to // remember we've seen version 1. var ids = Object.keys(txnLog.created), i, globalTVar; for (i = 0; i < ids.length; i += 1) { globalTVar = this.toGlobalTVar(ids[i]); globalTVar.bump(); globalTVar.appendDependencies(dependencies); dependencies[globalTVar.id] = globalTVar.version; this.recordTVarVersion(ids[i], globalTVar); } }, retry: function (txnLog) { var txnId = txnLog.txnId, updates = {}, dependencies = {}, ok, self, fired, observer, ids, i, localTVar, globalTVar; ok = this.create(txnLog); ok = this.checkReads(txnLog, updates) && ok; this.bumpCreated(txnLog, dependencies); if (ok) { // -fired is there to make sure we only send the // updates to this client once // - updates is there to collect all the updates // relevant for this txn together // - future is there to make sure we only do the // sending once the corresponding commit is fully done // and have thus built the biggest update we can self = this; fired = false; observer = function (globalTVar, future) { updates[globalTVar.id] = true; future.insert( updates, function () { if (!fired) { fired = true; self.sendUpdates(updates); self.write({type: 'result', txnId: txnId, result: 'restart'}); } }); }; ids = Object.keys(txnLog.read); for (i = 0; i < ids.length; i += 1) { (this.toGlobalTVar(ids[i])).subscribe(observer); } } else { // oh good, need to do updates already and can then // immediately restart the txn. this.sendUpdates(updates); this.write({type: 'result', txnId: txnId, result: 'restart'}); } }, commit: function (txnLog) { var txnId = txnLog.txnId, updates = {}, dependencies = {}, ok, future, globalIds, localIds, i, j, localTVar, globalTVar, names, name, desc; // Prevent a short-cut impl of && from causing us problems ok = this.create(txnLog); ok = this.checkReads(txnLog, updates) && ok; ok = this.checkWrites(txnLog, updates) && ok; this.bumpCreated(txnLog, dependencies); if (ok && this.onPreCommit(txnLog)) { // send out the success message first. This a) // improves performance; and more importantly b) // ensures that any updates that end up being sent to // the same client (due to pending retries) get // received after the success msg and thus we don't // fall out of step with vsns this.write({type: 'result', txnId: txnId, result: 'success'}); future = new CoalescingObserver(); // Once we've committed, and all tvars in this txn // have appended the new dependencies and been bumped, // only then consider rolling up. future.insert(dependencies, function () { globalIds = Object.keys(dependencies); for (i = 0; i < globalIds.length; i += 1) { globalTVars[globalIds[i]].rollUpTxnSets(); } }); localIds = Object.keys(txnLog.written); for (i = 0; i < localIds.length; i += 1) { localTVar = txnLog.written[localIds[i]]; globalTVar = this.toGlobalTVar(localIds[i]); names = Object.keys(localTVar); for (j = 0; j < names.length; j += 1) { name = names[j]; desc = localTVar[name]; if (util.hasOwnProp.call(desc, 'tvar')) { desc.value = {tvar: this.toGlobalTVarID(desc.tvar)}; delete desc.tvar; Object.defineProperty(globalTVar.raw, name, desc); } else if (util.hasOwnProp.call(desc, 'deleted')) { delete globalTVar.raw[name]; } else { // mess for dealing with arrays, and some proxy stuff if (globalTVar.isArray && 'length' === name) { globalTVar.raw[name] = desc.value; } else { Object.defineProperty(globalTVar.raw, name, desc); } } } globalTVar.bump(future); // wait until after the bump before recording new version globalTVar.appendDependencies(dependencies); dependencies[globalTVar.id] = globalTVar.version; this.recordTVarVersion(localIds[i], globalTVar); } future.force(); } else { this.sendUpdates(updates); this.write({type: 'result', txnId: txnId, result: 'failure'}); } }, onPreCommit: function (txnLog) { return true; }, sendUpdates: function (updates) { var globalIds = Object.keys(updates), unpopulateds = {}, txnLog = {type: "updates", updates: {}}, globalId, globalTVar, localId, localTVar, txnSet, globalIds1, globalId1, localId1, names, name, i, desc; while (0 < globalIds.length) { globalId = globalIds.shift(); globalTVar = globalTVars[globalId]; localTVar = {version: globalTVar.version, value: {}, isArray: globalTVar.isArray}; localId = this.toLocalTVarID(globalTVar.id); if (undefined === localId) { localId = this.addToMapping(globalTVar.id); txnLog.updates[localId] = localTVar; } else { if (this.localTVarVersion(localId) === globalTVar.version) { // Some earlier update caused us to send the // new version of this TVar down to the // client. Thus skip it here. continue; } else { txnLog.updates[localId] = localTVar; } } // Expand based on the dependencies of the globalTVar's txnSet chain txnSet = this.localTVarTxnSet(localId); if (undefined === txnSet) { txnSet = globalTVar.firstTxnSet; } while (undefined !== txnSet) { globalIds1 = Object.keys(txnSet.dependencies); for (i = 0; i < globalIds1.length; i += 1) { globalId1 = globalIds1[i]; if (! util.hasOwnProp.call(updates, globalId1)) { localId1 = this.toLocalTVarID(globalId1); if (undefined !== localId1 && this.localTVarVersion(localId1) < txnSet.dependencies[globalId1]) { globalIds.push(globalId1); updates[globalId1] = true; } } } txnSet = txnSet.next; } if (util.hasOwnProp.call(unpopulateds, globalId)) { localTVar.version = 0; this.recordTVarUnpopulated(localId, globalTVar); } else { this.recordTVarVersion(localId, globalTVar); // Expand based on the object's fields names = Object.getOwnPropertyNames(globalTVar.raw); names = this.securityProvider.filterUpdateNames(this, globalTVar, names); for (i = 0; i < names.length; i += 1) { name = names[i]; desc = Object.getOwnPropertyDescriptor(globalTVar.raw, name); desc = this.securityProvider.filterUpdateField(this, globalTVar, name, desc); localTVar.value[name] = desc; if ((!util.isPrimitive(desc.value)) && util.hasOwnProp.call(desc.value, 'tvar')) { localId1 = this.toLocalTVarID(desc.value.tvar); if (undefined === localId1) { if (! util.hasOwnProp.call(updates, desc.value.tvar)) { unpopulateds[desc.value.tvar] = true; globalIds.push(desc.value.tvar); } desc.tvar = this.addToMapping(desc.value.tvar); } else { desc.tvar = localId1; } // we are sending this tvar. So we can happily // ensure that no other txnSet can ask us to // send it updates[desc.value.tvar] = true; delete desc.value; } } } } if (0 < Object.keys(txnLog.updates).length) { this.write(txnLog); return true; } return false; } }; function ServerEventEmitter (securityProvider) { this.securityProvider = securityProvider; this.emitter = new events.EventEmitter(); util.liftFunctions( this.emitter, this, ['on', 'once', 'removeListener', 'removeAllListeners', 'emit']); this.on('connection', this.defaultAuthenticator.bind(this)); } ServerEventEmitter.prototype = { connection: function (connection) { new Client(connection, this); }, defaultAuthenticator: function (client) { if (1 === this.emitter.listeners('connection').length) { // nothing installed other than us, just let it straight through! client.isAuthenticated = true; } } }; function DefaultSecurityProvider () { } DefaultSecurityProvider.prototype = { lifted: function (client, globalTVar, meta) { return true; }, read: function (client, globalTVar, localTVar) { return true; }, written: function (client, globalTVar, localTVar) { return true; }, filterUpdateNames: function (client, globalTVar, fieldNames) { return fieldNames; }, filterUpdateField: function (client, globalTVar, fieldName, desc) { return desc; } }; create = function (http, path, securityProvider, root) { var server = sockjs.createServer(sockjs_opts), emitter; if (undefined === path || null === path) { path = '[/]atomize'; } if (undefined === securityProvider || null === securityProvider) { securityProvider = new DefaultSecurityProvider(); } emitter = new ServerEventEmitter(securityProvider); server.on('connection', emitter.connection.bind(emitter)); http.addListener('upgrade', function (req, res) { res.end(); }); server.installHandlers(http, {prefix: path}); if (undefined === root || null === root || util.isPrimitive(root)) { root = {}; } globalTVarCount = 1; rootTVar = new TVar(globalTVarCount, Array.isArray(root), root); globalTVars[rootTVar.id] = rootTVar; rootTVar.bump(); emitter.client = function () { return new atomize.Atomize(Client, emitter); }; return emitter; }; exports.create = create; }());