From 9b4be7b11308cd47e232d25b6f043b33db960046 Mon Sep 17 00:00:00 2001 From: Jonathan Friedman Date: Sat, 14 Sep 2013 20:05:54 -0700 Subject: [PATCH 01/21] dev: begin upgrade work --- README.markdown | 26 ++--- lib/connect-mysql-session.coffee | 121 +++++++++++++++++++++ lib/connect-mysql-session.js | 178 ------------------------------- package.json | 9 +- 4 files changed, 137 insertions(+), 197 deletions(-) create mode 100644 lib/connect-mysql-session.coffee delete mode 100644 lib/connect-mysql-session.js diff --git a/README.markdown b/README.markdown index 9dddd0b..138151e 100644 --- a/README.markdown +++ b/README.markdown @@ -3,23 +3,17 @@ connect-mysql-session A MySQL session store for the [connectjs][] [session middleware][] for [node.js][]. -Currently, this code appears to function correctly but it has not been optimized for performance. The store is implemented using [sequelize][] ORM, simply dumping the JSON-serialized session into a MySQL TEXT column. +An highly optimized dependency-reduced version of original work by "CarnegieLearning". + +Optimizations/Deltas +-------------------- +* Now written (and maintained) in Coffeescript +* Removed unnecessary dependence on Sequelize +* Switched to mySql's in-memory database engine Installation ------------ -Using [npm][]: - - npm install -g connect-mysql-session - -By cloning the repo: - - git clone git://github.com/CarnegieLearning/connect-mysql-session.git - cd connect-mysql-session - npm link - -(Note: in both cases you may need to use `sudo` when performing the `npm` step.) - Usage ----- @@ -56,6 +50,12 @@ Default: `1000*60*60*24` (1 day). How long session data is stored for "user sess Changes ------- +### 0.2.0 (2013-09-14) + +* Switch to Coffeescript +* Removed Sequelize +* Built on memory engine (MUCH more performant) + ### 0.1.1 and 0.1.2 (2011-08-03) ### * Lazy initialization to ensure model is ready before accessing. diff --git a/lib/connect-mysql-session.coffee b/lib/connect-mysql-session.coffee new file mode 100644 index 0000000..2bd0450 --- /dev/null +++ b/lib/connect-mysql-session.coffee @@ -0,0 +1,121 @@ +Sequelize = require("sequelize") +module.exports = (connect) -> + MySQLStore = (database, user, password, options) -> + # default 10 minutes. + # default 1 day. + initialize = (callback) -> + unless initialized + sequelize.sync(force: forceSync).on("success", -> + console.log "MySQL session store initialized." + initialized = true + callback() + ).on "failure", (error) -> + console.log "Failed to initialize MySQL session store:" + console.log error + callback error + + options = options or {} + connect.session.Store.call this, options + self = this + forceSync = options.forceSync or false + checkExpirationInterval = options.checkExpirationInterval or 1000 * 60 * 10 + defaultExpiration = options.defaultExpiration or 1000 * 60 * 60 * 24 + sequelize = new Sequelize(database, user, password, options) + Session = sequelize.define("Session", + sid: + type: Sequelize.STRING + unique: true + allowNull: false + + expires: Sequelize.INTEGER + json: Sequelize.TEXT + ) + initialized = false + + # Check periodically to clear out expired sessions. + setInterval (-> + initialize (error) -> + return if error + Session.findAll(where: ["expires < ?", Math.round(Date.now() / 1000)]).on("success", (sessions) -> + if sessions.length > 0 + console.log "Destroying " + sessions.length + " expired sessions." + for i of sessions + sessions[i].destroy() + ).on "failure", (error) -> + console.log "Failed to fetch expired sessions:" + console.log error + + + ), checkExpirationInterval + @get = (sid, fn) -> + initialize (error) -> + return fn(error, null) if error + Session.find(where: + sid: sid + ).on("success", (record) -> + session = record and JSON.parse(record.json) + fn null, session + ).on "failure", (error) -> + fn error, null + + + + @set = (sid, session, fn) -> + initialize (error) -> + return fn and fn(error) if error + + # Set expiration to match the cookie or 1 year in the future if unspecified. + + # Note: JS uses milliseconds, but we want integer seconds. + Session.find(where: + sid: sid + ).on("success", (record) -> + record = Session.build(sid: sid) unless record + record.json = JSON.stringify(session) + expires = session.cookie.expires or new Date(Date.now() + defaultExpiration) + record.expires = Math.round(expires.getTime() / 1000) + record.save().on("success", -> + fn and fn() + ).on "failure", (error) -> + fn and fn(error) + + ).on "failure", (error) -> + fn and fn(error) + + + + @destroy = (sid, fn) -> + initialize (error) -> + return fn and fn(error) if error + Session.find(where: + sid: sid + ).on("success", (record) -> + if record + record.destroy().on("success", -> + fn and fn() + ).on "failure", (error) -> + console.log "Session " + sid + " could not be destroyed:" + console.log error + fn and fn(error) + + else + fn and fn() + ).on "failure", (error) -> + fn and fn(error) + + + + @length = (callback) -> + initialize (error) -> + return callback(null) if error + Session.count().on("success", callback).on "failure", -> + callback null + + + + @clear = (callback) -> + sequelize.sync + force: true + , callback + MySQLStore::__proto__ = connect.session.Store:: + MySQLStore \ No newline at end of file diff --git a/lib/connect-mysql-session.js b/lib/connect-mysql-session.js deleted file mode 100644 index bf33c96..0000000 --- a/lib/connect-mysql-session.js +++ /dev/null @@ -1,178 +0,0 @@ -var Sequelize = require('sequelize'); - -module.exports = function (connect) -{ - function MySQLStore(database, user, password, options) - { - options = options || {}; - connect.session.Store.call(this, options); - - var self = this, - forceSync = options.forceSync || false, - checkExpirationInterval = options.checkExpirationInterval || 1000*60*10, // default 10 minutes. - defaultExpiration = options.defaultExpiration || 1000*60*60*24; // default 1 day. - - var sequelize = new Sequelize(database, user, password, options); - - var Session = sequelize.define('Session', { - sid: {type: Sequelize.STRING, unique: true, allowNull: false}, - expires: Sequelize.INTEGER, - json: Sequelize.TEXT - }); - - var initialized = false; - - function initialize(callback) - { - if (initialized) callback(); - else - { - sequelize.sync({force: forceSync}) - .on('success', function () - { - console.log('MySQL session store initialized.'); - initialized = true; - callback(); - }) - .on('failure', function (error) - { - console.log('Failed to initialize MySQL session store:'); - console.log(error); - callback(error); - }); - } - } - - // Check periodically to clear out expired sessions. - setInterval(function () - { - initialize(function (error) - { - if (error) return; - Session.findAll({where: ['expires < ?', Math.round(Date.now() / 1000)]}) - .on('success', function (sessions) - { - if (sessions.length > 0) - { - console.log('Destroying ' + sessions.length + ' expired sessions.'); - for (var i in sessions) - { - sessions[i].destroy(); - } - } - }) - .on('failure', function (error) - { - console.log('Failed to fetch expired sessions:'); - console.log(error); - }); - }); - }, checkExpirationInterval); - - this.get = function (sid, fn) - { - initialize(function (error) - { - if (error) return fn(error, null); - Session.find({where: {sid: sid}}) - .on('success', function (record) - { - var session = record && JSON.parse(record.json); - fn(null, session); - }) - .on('failure', function (error) - { - fn(error, null); - }); - }); - }; - - this.set = function (sid, session, fn) - { - initialize(function (error) - { - if (error) return fn && fn(error); - Session.find({where: {sid: sid}}) - .on('success', function (record) - { - if (!record) - { - record = Session.build({sid: sid}); - } - record.json = JSON.stringify(session); - - // Set expiration to match the cookie or 1 year in the future if unspecified. - var expires = session.cookie.expires || - new Date(Date.now() + defaultExpiration); - // Note: JS uses milliseconds, but we want integer seconds. - record.expires = Math.round(expires.getTime() / 1000); - - record.save() - .on('success', function () - { - fn && fn(); - }) - .on('failure', function (error) - { - fn && fn(error); - }); - }) - .on('failure', function (error) - { - fn && fn(error); - }); - }); - }; - - this.destroy = function (sid, fn) - { - initialize(function (error) - { - if (error) return fn && fn(error); - Session.find({where: {sid: sid}}) - .on('success', function (record) - { - if (record) - { - record.destroy() - .on('success', function () - { - fn && fn(); - }) - .on('failure', function (error) - { - console.log('Session ' + sid + ' could not be destroyed:'); - console.log(error); - fn && fn(error); - }); - } - else fn && fn(); - }) - .on('failure', function (error) - { - fn && fn(error); - }); - }); - }; - - this.length = function (callback) - { - initialize(function (error) - { - if (error) return callback(null); - Session.count() - .on('success', callback) - .on('failure', function () { callback(null); }); - }); - }; - - this.clear = function (callback) - { - sequelize.sync({force: true}, callback); - }; - } - - MySQLStore.prototype.__proto__ = connect.session.Store.prototype; - - return MySQLStore; -}; diff --git a/package.json b/package.json index 1275a7f..808c219 100644 --- a/package.json +++ b/package.json @@ -2,19 +2,16 @@ "author": "Daniel Dickison ", "name": "connect-mysql-session", "description": "A MySQL session store for node.js connect.", - "version": "0.1.3", + "version": "0.2.0", "repository": { "type": "git", "url": "git://github.com/CarnegieLearning/connect-mysql-session.git" }, "main": "./index.js", "engines": { - "node": "~0.4" + "node": ">0.8" }, "dependencies": { - "sequelize": "~1.1" - }, - "devDependencies": { - "connect": "~1.6" + "node-mysql": "*" } } \ No newline at end of file From 4a5fa2e08b14d35d350c2416c08f1110acd3fb2b Mon Sep 17 00:00:00 2001 From: Jonathan Friedman Date: Sun, 15 Sep 2013 01:45:58 -0700 Subject: [PATCH 02/21] dev: rebuilding on memory engine and removal of sequelize dependency --- lib/connect-mysql-session.coffee | 159 +++++++++++++++++++------------ package.json | 2 +- 2 files changed, 98 insertions(+), 63 deletions(-) diff --git a/lib/connect-mysql-session.coffee b/lib/connect-mysql-session.coffee index 2bd0450..9f503d2 100644 --- a/lib/connect-mysql-session.coffee +++ b/lib/connect-mysql-session.coffee @@ -1,65 +1,106 @@ -Sequelize = require("sequelize") +mysql = require "mysql" + module.exports = (connect) -> - MySQLStore = (database, user, password, options) -> - # default 10 minutes. - # default 1 day. + + ### + connection = + host: name of the database's host + user: login username + password: login password + options = + checkExpirationInterval: (in minutes) + defaultExpiration: (in minutes) + ### + MySQLStore = (_connection, _options) -> + + + + ### + Default values + ### + _connection ?= {} + _connection.host ?= "127.0.0.1" + _connection.user ?= "root" + _connection.password ?= "" + connection = mysql.createConnection _connection + _options ?= {} + _options.checkExpirationInterval ?= 24*60 #check once a day + _options.defaultExpiration ?= 7*24*60 #expire after one week + options = _options + + + + ### + Connect & Initialize MySQL Engine + ### initialize = (callback) -> unless initialized - sequelize.sync(force: forceSync).on("success", -> - console.log "MySQL session store initialized." - initialized = true - callback() - ).on "failure", (error) -> - console.log "Failed to initialize MySQL session store:" - console.log error - callback error + connection.connect() + sql = """ + CREATE DATABASE IF NOT EXISTS `sessions` + """ + connection.query sql, (err, rows, fields) -> + if err? + console.log "Failed to initialize MySQL session store. Couldn't create sessions database.", err + callback err; return + sql = """ + CREATE TABLE IF NOT EXISTS `sessions`.`session` ( + `sid` varchar(40) NOT NULL DEFAULT '', + `expires` int(11) DEFAULT NULL, + `json` varchar(4096) DEFAULT '', + PRIMARY KEY (`sid`) + ) + ENGINE=MEMORY + DEFAULT CHARSET=utf8 + """ + connection.query sql, (err, rows, fields) -> + if err? + console.log "Failed to initialize MySQL session store. Couldn't create session table.", err + callback err; return + console.log "MySQL session store initialized." + initialized = true + callback() - options = options or {} connect.session.Store.call this, options self = this - forceSync = options.forceSync or false - checkExpirationInterval = options.checkExpirationInterval or 1000 * 60 * 10 - defaultExpiration = options.defaultExpiration or 1000 * 60 * 60 * 24 - sequelize = new Sequelize(database, user, password, options) - Session = sequelize.define("Session", - sid: - type: Sequelize.STRING - unique: true - allowNull: false - - expires: Sequelize.INTEGER - json: Sequelize.TEXT - ) initialized = false - # Check periodically to clear out expired sessions. + + + ### + Check periodically to clear out expired sessions. + ### setInterval (-> initialize (error) -> - return if error - Session.findAll(where: ["expires < ?", Math.round(Date.now() / 1000)]).on("success", (sessions) -> - if sessions.length > 0 - console.log "Destroying " + sessions.length + " expired sessions." - for i of sessions - sessions[i].destroy() - ).on "failure", (error) -> - console.log "Failed to fetch expired sessions:" - console.log error + return if error + sql = """ + DELETE FROM `sessions`.`SESSION` WHERE expires < ? + """ + connection.query sql, [Math.round(Date.now() / 1000)], (err, rows, fields) -> + if err? + console.log "Failed to fetch expired sessions:", err + return + console.log "Destroying " + rows.length + " expired sessions." + ), checkExpirationInterval - ), checkExpirationInterval + + ### + Retrieve the session data + ### @get = (sid, fn) -> initialize (error) -> - return fn(error, null) if error - Session.find(where: - sid: sid - ).on("success", (record) -> - session = record and JSON.parse(record.json) - fn null, session - ).on "failure", (error) -> - fn error, null - + return fn(error, null) if error? + connection.query "SELECT * FROM `sessions`.`session` WHERE `sid`=?",[sid], (err, rows, fields) -> + if err? + fn err, undefined + return + fn undefined, JSON.parse(rows[0].json) + ### + Write to the user session + ### @set = (sid, session, fn) -> initialize (error) -> return fn and fn(error) if error @@ -84,24 +125,18 @@ module.exports = (connect) -> + ### + Delete the user session + ### @destroy = (sid, fn) -> initialize (error) -> - return fn and fn(error) if error - Session.find(where: - sid: sid - ).on("success", (record) -> - if record - record.destroy().on("success", -> - fn and fn() - ).on "failure", (error) -> - console.log "Session " + sid + " could not be destroyed:" - console.log error - fn and fn(error) - - else - fn and fn() - ).on "failure", (error) -> - fn and fn(error) + return fn(error, null) if error? + connection.query "DELETE FROM `sessions`.`session` WHERE `sid`=?",[sid], (err, rows, fields) -> + if err? + console.log "Session " + sid + " could not be destroyed." + fn err, undefined + return + fn() diff --git a/package.json b/package.json index 808c219..014ab14 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,6 @@ "node": ">0.8" }, "dependencies": { - "node-mysql": "*" + "mysql": "*" } } \ No newline at end of file From eaec12d8ffccee7f108c6d71c4d8d175394243a2 Mon Sep 17 00:00:00 2001 From: Jonathan Friedman Date: Sun, 15 Sep 2013 02:16:00 -0700 Subject: [PATCH 03/21] dev: finished core construction --- lib/connect-mysql-session.coffee | 79 ++++++++++++++------------------ 1 file changed, 34 insertions(+), 45 deletions(-) diff --git a/lib/connect-mysql-session.coffee b/lib/connect-mysql-session.coffee index 9f503d2..51a778a 100644 --- a/lib/connect-mysql-session.coffee +++ b/lib/connect-mysql-session.coffee @@ -13,8 +13,6 @@ module.exports = (connect) -> ### MySQLStore = (_connection, _options) -> - - ### Default values ### @@ -28,8 +26,6 @@ module.exports = (connect) -> _options.defaultExpiration ?= 7*24*60 #expire after one week options = _options - - ### Connect & Initialize MySQL Engine ### @@ -60,13 +56,10 @@ module.exports = (connect) -> console.log "MySQL session store initialized." initialized = true callback() - connect.session.Store.call this, options self = this initialized = false - - ### Check periodically to clear out expired sessions. ### @@ -74,7 +67,7 @@ module.exports = (connect) -> initialize (error) -> return if error sql = """ - DELETE FROM `sessions`.`SESSION` WHERE expires < ? + DELETE FROM `sessions`.`session` WHERE expires < ? """ connection.query sql, [Math.round(Date.now() / 1000)], (err, rows, fields) -> if err? @@ -83,54 +76,46 @@ module.exports = (connect) -> console.log "Destroying " + rows.length + " expired sessions." ), checkExpirationInterval - - ### Retrieve the session data ### @get = (sid, fn) -> initialize (error) -> - return fn(error, null) if error? + return fn(error) if error? connection.query "SELECT * FROM `sessions`.`session` WHERE `sid`=?",[sid], (err, rows, fields) -> if err? fn err, undefined return fn undefined, JSON.parse(rows[0].json) - ### Write to the user session ### @set = (sid, session, fn) -> initialize (error) -> - return fn and fn(error) if error - - # Set expiration to match the cookie or 1 year in the future if unspecified. - - # Note: JS uses milliseconds, but we want integer seconds. - Session.find(where: - sid: sid - ).on("success", (record) -> - record = Session.build(sid: sid) unless record - record.json = JSON.stringify(session) - expires = session.cookie.expires or new Date(Date.now() + defaultExpiration) - record.expires = Math.round(expires.getTime() / 1000) - record.save().on("success", -> - fn and fn() - ).on "failure", (error) -> - fn and fn(error) - - ).on "failure", (error) -> - fn and fn(error) - - - + return fn(error, null) if error? + connection.query "DELETE FROM `sessions`.`session` WHERE `sid`=?", [sid], (err) -> + return fn err if err? + # Set expiration to match the cookie or 1 year in the future if unspecified. + # Note: JS uses milliseconds, but we want integer seconds. + record = + sid: sid + json: JSON.stringify(session) + expires: session.cookie.expires or new Date(Date.now() + defaultExpiration) + record.expires = Math.round(expires.getTime() / 1000) + sql = """ + INSERT INTO `sessions`.`session` (`sid`, `expires`, `json`) VALUES (?, ?, ?) + """ + connection.query sql, [sid, expires, json], (err) -> + return fn err if err? + fn() + ### Delete the user session ### @destroy = (sid, fn) -> initialize (error) -> - return fn(error, null) if error? + return fn(error) if error? connection.query "DELETE FROM `sessions`.`session` WHERE `sid`=?",[sid], (err, rows, fields) -> if err? console.log "Session " + sid + " could not be destroyed." @@ -138,19 +123,23 @@ module.exports = (connect) -> return fn() - - + ### + Number of users with active sessions + ### @length = (callback) -> initialize (error) -> - return callback(null) if error - Session.count().on("success", callback).on "failure", -> - callback null - - + return callback error if error? + connection.query "SELECT * FROM `sessions`.`session`", (err, rows) -> + return callback err if err? + callback undefined, rows.length + ### + Remove all user sessions (reset/log everyone out) + ### @clear = (callback) -> - sequelize.sync - force: true - , callback + initialize (error) -> + return callback error if error? + connection.query "DELETE FROM `sessions`.`session`", callback + MySQLStore::__proto__ = connect.session.Store:: MySQLStore \ No newline at end of file From f04895ca0939958d22cd28fa25dd96d40ac93bbc Mon Sep 17 00:00:00 2001 From: Jonathan Friedman Date: Sun, 15 Sep 2013 02:54:08 -0700 Subject: [PATCH 04/21] bugfix: options.x references --- lib/connect-mysql-session.coffee | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/connect-mysql-session.coffee b/lib/connect-mysql-session.coffee index 51a778a..e96e8be 100644 --- a/lib/connect-mysql-session.coffee +++ b/lib/connect-mysql-session.coffee @@ -74,7 +74,7 @@ module.exports = (connect) -> console.log "Failed to fetch expired sessions:", err return console.log "Destroying " + rows.length + " expired sessions." - ), checkExpirationInterval + ), options.checkExpirationInterval ### Retrieve the session data @@ -101,7 +101,7 @@ module.exports = (connect) -> record = sid: sid json: JSON.stringify(session) - expires: session.cookie.expires or new Date(Date.now() + defaultExpiration) + expires: session.cookie.expires or new Date(Date.now() + options.defaultExpiration) record.expires = Math.round(expires.getTime() / 1000) sql = """ INSERT INTO `sessions`.`session` (`sid`, `expires`, `json`) VALUES (?, ?, ?) diff --git a/package.json b/package.json index 014ab14..a53be93 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Daniel Dickison ", "name": "connect-mysql-session", "description": "A MySQL session store for node.js connect.", - "version": "0.2.0", + "version": "0.2.1", "repository": { "type": "git", "url": "git://github.com/CarnegieLearning/connect-mysql-session.git" From b9c87b40220f5d48191260f2743a105b68285240 Mon Sep 17 00:00:00 2001 From: Jonathan Friedman Date: Sun, 15 Sep 2013 03:02:30 -0700 Subject: [PATCH 05/21] dev: investigating missing function --- lib/connect-mysql-session.coffee | 3 ++- package.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/connect-mysql-session.coffee b/lib/connect-mysql-session.coffee index e96e8be..78bf8f0 100644 --- a/lib/connect-mysql-session.coffee +++ b/lib/connect-mysql-session.coffee @@ -139,7 +139,8 @@ module.exports = (connect) -> @clear = (callback) -> initialize (error) -> return callback error if error? - connection.query "DELETE FROM `sessions`.`session`", callback + connection.query "DELETE FROM `sessions`.`session`", (err) -> + callback err MySQLStore::__proto__ = connect.session.Store:: MySQLStore \ No newline at end of file diff --git a/package.json b/package.json index a53be93..27c14c0 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Daniel Dickison ", "name": "connect-mysql-session", "description": "A MySQL session store for node.js connect.", - "version": "0.2.1", + "version": "0.2.2", "repository": { "type": "git", "url": "git://github.com/CarnegieLearning/connect-mysql-session.git" From 5c62a1163e1251e764824b0d5a57105ba4480b89 Mon Sep 17 00:00:00 2001 From: Jonathan Friedman Date: Sun, 15 Sep 2013 03:21:28 -0700 Subject: [PATCH 06/21] bugfix: don't interfere with session prototype --- lib/connect-mysql-session.coffee | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/connect-mysql-session.coffee b/lib/connect-mysql-session.coffee index 78bf8f0..22f2f75 100644 --- a/lib/connect-mysql-session.coffee +++ b/lib/connect-mysql-session.coffee @@ -142,5 +142,5 @@ module.exports = (connect) -> connection.query "DELETE FROM `sessions`.`session`", (err) -> callback err - MySQLStore::__proto__ = connect.session.Store:: - MySQLStore \ No newline at end of file + #MySQLStore::__proto__ = connect.session.Store:: + return MySQLStore \ No newline at end of file diff --git a/package.json b/package.json index 27c14c0..74339a0 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Daniel Dickison ", "name": "connect-mysql-session", "description": "A MySQL session store for node.js connect.", - "version": "0.2.2", + "version": "0.2.4", "repository": { "type": "git", "url": "git://github.com/CarnegieLearning/connect-mysql-session.git" From aa602b92afbcb18195ce1475ce1dc7475583e7e2 Mon Sep 17 00:00:00 2001 From: Jonathan Friedman Date: Sun, 15 Sep 2013 10:38:01 -0700 Subject: [PATCH 07/21] dev: complete rewrite --- lib/connect-mysql-session.coffee | 223 ++++++++++++++----------------- 1 file changed, 98 insertions(+), 125 deletions(-) diff --git a/lib/connect-mysql-session.coffee b/lib/connect-mysql-session.coffee index 22f2f75..26470f4 100644 --- a/lib/connect-mysql-session.coffee +++ b/lib/connect-mysql-session.coffee @@ -2,145 +2,118 @@ mysql = require "mysql" module.exports = (connect) -> + Store = connect.session.Store + ### - connection = + options = host: name of the database's host user: login username password: login password - options = - checkExpirationInterval: (in minutes) - defaultExpiration: (in minutes) + checkExpirationInterval: (in seconds) + defaultExpiration: (in seconds) + client: (optional) fully instantiated client to use, instead of creating one internally + ttl: no idea yet... ### - MySQLStore = (_connection, _options) -> - - ### - Default values - ### - _connection ?= {} - _connection.host ?= "127.0.0.1" - _connection.user ?= "root" - _connection.password ?= "" - connection = mysql.createConnection _connection - _options ?= {} - _options.checkExpirationInterval ?= 24*60 #check once a day - _options.defaultExpiration ?= 7*24*60 #expire after one week - options = _options - ### - Connect & Initialize MySQL Engine - ### - initialize = (callback) -> - unless initialized - connection.connect() - sql = """ - CREATE DATABASE IF NOT EXISTS `sessions` - """ - connection.query sql, (err, rows, fields) -> - if err? - console.log "Failed to initialize MySQL session store. Couldn't create sessions database.", err - callback err; return - sql = """ - CREATE TABLE IF NOT EXISTS `sessions`.`session` ( - `sid` varchar(40) NOT NULL DEFAULT '', - `expires` int(11) DEFAULT NULL, - `json` varchar(4096) DEFAULT '', - PRIMARY KEY (`sid`) - ) - ENGINE=MEMORY - DEFAULT CHARSET=utf8 - """ - connection.query sql, (err, rows, fields) -> - if err? - console.log "Failed to initialize MySQL session store. Couldn't create session table.", err - callback err; return - console.log "MySQL session store initialized." - initialized = true - callback() - connect.session.Store.call this, options - self = this - initialized = false - - ### - Check periodically to clear out expired sessions. - ### - setInterval (-> - initialize (error) -> - return if error + MySqlStore = (options) => + # -- Context + @initialized = false + # -- Default values + options = options or {} + options.host ?= "127.0.0.1" + options.user ?= "root" + options.password ?= "" + options.checkExpirationInterval ?= 24*60*60 #check once a day + options.defaultExpiration ?= 7*24*60*60 #expire after one week + @options = options + # -- Link middleware + Store.call this, options + # -- Create client + @client = options.client or mysql.createConnection options + @initialize() #ignore callback for now. Just an optimization to initialize faster + @client.on "error", => + @emit "disconnect" + @client.on "connect", => + @emit "connect" + + + ### + Inherit from `Store`. + ### + MySqlStore::__proto__ = Store:: + + + MySqlStore::initialize = (fn) => + console.log "DATABASE!" + unless @initialized + @client.connect() + sql = """ + CREATE DATABASE IF NOT EXISTS `sessions` + """ + @client.query sql, (err, rows, fields) => + if err? + console.log "Failed to initialize MySQL session store. Couldn't create sessions database.", err + return fn err sql = """ - DELETE FROM `sessions`.`session` WHERE expires < ? + CREATE TABLE IF NOT EXISTS `sessions`.`session` ( + `sid` varchar(40) NOT NULL DEFAULT '', + `ttl` int(11) DEFAULT NULL, + `json` varchar(4096) DEFAULT '', + PRIMARY KEY (`sid`) + ) + ENGINE=MEMORY + DEFAULT CHARSET=utf8 """ - connection.query sql, [Math.round(Date.now() / 1000)], (err, rows, fields) -> + @client.query sql, (err, rows, fields) => if err? - console.log "Failed to fetch expired sessions:", err - return - console.log "Destroying " + rows.length + " expired sessions." - ), options.checkExpirationInterval + console.log "Failed to initialize MySQL session store. Couldn't create session table.", err + return fn err + console.log "MySQL session store initialized." + @initialized = true + fn() + + + MySqlStore::get = (sid, fn) => + console.log "GET", sid + @initialize (error) => + return fn error if error? + @client.query "SELECT * FROM `sessions`.`session` WHERE `sid`=?", [sid], (err, rows, fields) => + return fn err if err? + console.log "GOT", rows[0] + result = undefined + try + result = JSON.parse rows[0].json + catch err + return fn err + fn undefined, result - ### - Retrieve the session data - ### - @get = (sid, fn) -> - initialize (error) -> - return fn(error) if error? - connection.query "SELECT * FROM `sessions`.`session` WHERE `sid`=?",[sid], (err, rows, fields) -> - if err? - fn err, undefined - return - fn undefined, JSON.parse(rows[0].json) - ### - Write to the user session - ### - @set = (sid, session, fn) -> - initialize (error) -> - return fn(error, null) if error? - connection.query "DELETE FROM `sessions`.`session` WHERE `sid`=?", [sid], (err) -> + MySqlStore::set = (sid, session, fn) => + try + maxAge = session.cookie.maxAge + ttl = @options.ttl + json = JSON.stringify(session) + ttl = ttl or ((if "number" is typeof maxAge then maxAge / 1000 | 0 else @options.defaultExpiration)) + console.log "SET", sid, ttl, json + @initialize (error) => + return fn error if error? + @client.query "DELETE FROM `sessions`.`session` WHERE `sid`=?", [sid], (err) => return fn err if err? - # Set expiration to match the cookie or 1 year in the future if unspecified. - # Note: JS uses milliseconds, but we want integer seconds. - record = - sid: sid - json: JSON.stringify(session) - expires: session.cookie.expires or new Date(Date.now() + options.defaultExpiration) - record.expires = Math.round(expires.getTime() / 1000) - sql = """ - INSERT INTO `sessions`.`session` (`sid`, `expires`, `json`) VALUES (?, ?, ?) - """ - connection.query sql, [sid, expires, json], (err) -> + sql = "INSERT INTO `sessions`.`session` (`sid`, `ttl`, `json`) VALUES (?, ?, ?)" + @client.query sql, [sid, ttl, json], (err) => return fn err if err? fn() - - ### - Delete the user session - ### - @destroy = (sid, fn) -> - initialize (error) -> - return fn(error) if error? - connection.query "DELETE FROM `sessions`.`session` WHERE `sid`=?",[sid], (err, rows, fields) -> - if err? - console.log "Session " + sid + " could not be destroyed." - fn err, undefined - return - fn() + catch err + fn and fn(err) - ### - Number of users with active sessions - ### - @length = (callback) -> - initialize (error) -> - return callback error if error? - connection.query "SELECT * FROM `sessions`.`session`", (err, rows) -> - return callback err if err? - callback undefined, rows.length - ### - Remove all user sessions (reset/log everyone out) - ### - @clear = (callback) -> - initialize (error) -> - return callback error if error? - connection.query "DELETE FROM `sessions`.`session`", (err) -> - callback err + MySqlStore::destroy = (sid, fn) => + @initialize (error) => + return fn error if error? + @client.query "DELETE FROM `sessions`.`session` WHERE `sid`=?",[sid], (err, rows, fields) -> + if err? + console.log "Session " + sid + " could not be destroyed." + return fn err, undefined + fn() - #MySQLStore::__proto__ = connect.session.Store:: - return MySQLStore \ No newline at end of file + return MySqlStore \ No newline at end of file From 7fc2d6342cc50e59610aba949c5b1585a067294a Mon Sep 17 00:00:00 2001 From: Jonathan Friedman Date: Sun, 15 Sep 2013 10:38:39 -0700 Subject: [PATCH 08/21] dev: uprev --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 74339a0..b41e133 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Daniel Dickison ", "name": "connect-mysql-session", "description": "A MySQL session store for node.js connect.", - "version": "0.2.4", + "version": "0.2.5", "repository": { "type": "git", "url": "git://github.com/CarnegieLearning/connect-mysql-session.git" From bf96e04760664181eff2c698ea66ac175811a9a2 Mon Sep 17 00:00:00 2001 From: Jonathan Friedman Date: Sun, 15 Sep 2013 10:44:16 -0700 Subject: [PATCH 09/21] bugfix: remove early reference --- lib/connect-mysql-session.coffee | 1 - package.json | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/connect-mysql-session.coffee b/lib/connect-mysql-session.coffee index 26470f4..af43751 100644 --- a/lib/connect-mysql-session.coffee +++ b/lib/connect-mysql-session.coffee @@ -30,7 +30,6 @@ module.exports = (connect) -> Store.call this, options # -- Create client @client = options.client or mysql.createConnection options - @initialize() #ignore callback for now. Just an optimization to initialize faster @client.on "error", => @emit "disconnect" @client.on "connect", => diff --git a/package.json b/package.json index b41e133..fe85e5e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Daniel Dickison ", "name": "connect-mysql-session", "description": "A MySQL session store for node.js connect.", - "version": "0.2.5", + "version": "0.2.6", "repository": { "type": "git", "url": "git://github.com/CarnegieLearning/connect-mysql-session.git" From 9819b07e3f926b6fd2233b7b6efe4d16ac377ab5 Mon Sep 17 00:00:00 2001 From: Jonathan Friedman Date: Sun, 15 Sep 2013 11:53:36 -0700 Subject: [PATCH 10/21] bugfix: rewrite to coffeescript class; initialize nonce fixed --- lib/connect-mysql-session.coffee | 102 ++++++++++++++----------------- 1 file changed, 46 insertions(+), 56 deletions(-) diff --git a/lib/connect-mysql-session.coffee b/lib/connect-mysql-session.coffee index af43751..eee3f98 100644 --- a/lib/connect-mysql-session.coffee +++ b/lib/connect-mysql-session.coffee @@ -2,8 +2,6 @@ mysql = require "mysql" module.exports = (connect) -> - Store = connect.session.Store - ### options = host: name of the database's host @@ -15,36 +13,29 @@ module.exports = (connect) -> ttl: no idea yet... ### - MySqlStore = (options) => - # -- Context - @initialized = false - # -- Default values - options = options or {} - options.host ?= "127.0.0.1" - options.user ?= "root" - options.password ?= "" - options.checkExpirationInterval ?= 24*60*60 #check once a day - options.defaultExpiration ?= 7*24*60*60 #expire after one week - @options = options - # -- Link middleware - Store.call this, options - # -- Create client - @client = options.client or mysql.createConnection options - @client.on "error", => - @emit "disconnect" - @client.on "connect", => - @emit "connect" + class MySqlStore extends connect.session.Store + constructor: (@options) -> + # -- Context + @initialized = false + # -- Default values + options = options or {} + options.host ?= "127.0.0.1" + options.user ?= "root" + options.password ?= "" + options.checkExpirationInterval ?= 24*60*60 #check once a day + options.defaultExpiration ?= 7*24*60*60 #expire after one week + # -- Link middleware + connect.session.Store.call this, options + # -- Create client + @client = options.client or mysql.createConnection options + @client.on "error", => + @emit "disconnect" + @client.on "connect", => + @emit "connect" - - ### - Inherit from `Store`. - ### - MySqlStore::__proto__ = Store:: - - - MySqlStore::initialize = (fn) => - console.log "DATABASE!" - unless @initialized + initialize: (fn) => + fn() if @initialized #run only once + console.log "DATABASE!" @client.connect() sql = """ CREATE DATABASE IF NOT EXISTS `sessions` @@ -72,23 +63,22 @@ module.exports = (connect) -> fn() - MySqlStore::get = (sid, fn) => - console.log "GET", sid - @initialize (error) => - return fn error if error? - @client.query "SELECT * FROM `sessions`.`session` WHERE `sid`=?", [sid], (err, rows, fields) => - return fn err if err? - console.log "GOT", rows[0] - result = undefined - try - result = JSON.parse rows[0].json - catch err - return fn err - fn undefined, result + get: (sid, fn) => + console.log "GET", sid + @initialize (error) => + return fn error if error? + @client.query "SELECT * FROM `sessions`.`session` WHERE `sid`=?", [sid], (err, rows, fields) => + return fn err if err? + console.log "GOT", rows[0] + result = undefined + try + result = JSON.parse rows[0].json if rows?[0]? + catch err + return fn err + fn undefined, result - MySqlStore::set = (sid, session, fn) => - try + set: (sid, session, fn) => maxAge = session.cookie.maxAge ttl = @options.ttl json = JSON.stringify(session) @@ -99,20 +89,20 @@ module.exports = (connect) -> @client.query "DELETE FROM `sessions`.`session` WHERE `sid`=?", [sid], (err) => return fn err if err? sql = "INSERT INTO `sessions`.`session` (`sid`, `ttl`, `json`) VALUES (?, ?, ?)" + console.log "\n\n\n=-=-=[CONNECT.SET]", sql, "\n\n\n" #xxx @client.query sql, [sid, ttl, json], (err) => + console.log "SET!", err, sid, ttl, json return fn err if err? fn() - catch err - fn and fn(err) - MySqlStore::destroy = (sid, fn) => - @initialize (error) => - return fn error if error? - @client.query "DELETE FROM `sessions`.`session` WHERE `sid`=?",[sid], (err, rows, fields) -> - if err? - console.log "Session " + sid + " could not be destroyed." - return fn err, undefined - fn() + destroy: (sid, fn) => + @initialize (error) => + return fn error if error? + @client.query "DELETE FROM `sessions`.`session` WHERE `sid`=?",[sid], (err, rows, fields) -> + if err? + console.log "Session " + sid + " could not be destroyed." + return fn err, undefined + fn() return MySqlStore \ No newline at end of file From 2d3dd9d7fd914432f5d12ba4fa686c51c5c0e67b Mon Sep 17 00:00:00 2001 From: Jonathan Friedman Date: Sun, 15 Sep 2013 12:21:36 -0700 Subject: [PATCH 11/21] bugfix: set uses apply --- lib/connect-mysql-session.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/connect-mysql-session.coffee b/lib/connect-mysql-session.coffee index eee3f98..dd22892 100644 --- a/lib/connect-mysql-session.coffee +++ b/lib/connect-mysql-session.coffee @@ -93,7 +93,7 @@ module.exports = (connect) -> @client.query sql, [sid, ttl, json], (err) => console.log "SET!", err, sid, ttl, json return fn err if err? - fn() + fn.apply(this, arguments) destroy: (sid, fn) => From a9c6b09278ebb6fd141fc837eeed46f0a5659fa5 Mon Sep 17 00:00:00 2001 From: Jonathan Friedman Date: Sun, 15 Sep 2013 20:48:24 -0700 Subject: [PATCH 12/21] bugfix: eliminate access race condition by making update operations atomic --- README.markdown | 31 +++++++++++++++++++++++++ lib/connect-mysql-session.coffee | 39 ++++++++++++++++++-------------- 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/README.markdown b/README.markdown index 138151e..b443f08 100644 --- a/README.markdown +++ b/README.markdown @@ -11,6 +11,37 @@ Optimizations/Deltas * Removed unnecessary dependence on Sequelize * Switched to mySql's in-memory database engine +Why MySQL for Sessions? +------------------------ + +## Less Maintenance + +* If you already use MySQL for your primary data store, eliminating the use of Mongo or Redis reduces the number of vendors, number of critical failure points, and probability of failure in your system as a whole. + +* Reduced polyglot results from eliminating yet another domain specific language (Redis/Mongo) from your development stack. + +* You don't have to build/configure additional monitoring and management for your session store. Your primary data store automatically covers it. + +* Upgrades to your primary datastore automatically effect the session store. You don't need to perform two separate upgrades. + +## Lower Operating Costs + +* It is less expensive to scale existing technology (provision a larger database server), than to provision multiple smaller database servers + +* Fewer servers makes it less expensive to run staging and development copies of your infrastructure. + +* Fewer languages means less development time and fewer management and monitoring tools to buy. You are already monitoring your primary data store, why not just reuse that investment. + + +## Better performance + +MySQL in-memory table stores are about as efficient as data storage can get, primary due to its lack of features. Data is allocated in small blocks and indexed with a hash or binary tree. + +As [this study](http://www.aspieschool.com/wiki/index.php?title=Redis_vs_MySQL_(Benchmarks)) revealed, + +MySQL's Memory Engine can performed sustained writes at 92% the speed of Redis, yet performs reads at almost 25X (times!!!) faster. Given that session stores show a heavy read bias, the end result is a large performance gain. + + Installation ------------ diff --git a/lib/connect-mysql-session.coffee b/lib/connect-mysql-session.coffee index dd22892..db1af2a 100644 --- a/lib/connect-mysql-session.coffee +++ b/lib/connect-mysql-session.coffee @@ -18,24 +18,24 @@ module.exports = (connect) -> # -- Context @initialized = false # -- Default values - options = options or {} - options.host ?= "127.0.0.1" - options.user ?= "root" - options.password ?= "" - options.checkExpirationInterval ?= 24*60*60 #check once a day - options.defaultExpiration ?= 7*24*60*60 #expire after one week + @options = @options or {} + @options.host ?= "127.0.0.1" + @options.user ?= "root" + @options.password ?= "" + @options.checkExpirationInterval ?= 24*60*60 #check once a day + @options.defaultExpiration ?= 7*24*60*60 #expire after one week # -- Link middleware - connect.session.Store.call this, options + connect.session.Store.call this, @options # -- Create client - @client = options.client or mysql.createConnection options + @client = options.client or mysql.createConnection @options @client.on "error", => @emit "disconnect" @client.on "connect", => @emit "connect" initialize: (fn) => - fn() if @initialized #run only once - console.log "DATABASE!" + return fn() if @initialized #run only once + console.log "DATABASE!", @initialized @client.connect() sql = """ CREATE DATABASE IF NOT EXISTS `sessions` @@ -85,14 +85,19 @@ module.exports = (connect) -> ttl = ttl or ((if "number" is typeof maxAge then maxAge / 1000 | 0 else @options.defaultExpiration)) console.log "SET", sid, ttl, json @initialize (error) => + console.log "\n\n\n=-=-=[SET](1)", error, "\n\n\n" #xxx return fn error if error? - @client.query "DELETE FROM `sessions`.`session` WHERE `sid`=?", [sid], (err) => - return fn err if err? - sql = "INSERT INTO `sessions`.`session` (`sid`, `ttl`, `json`) VALUES (?, ?, ?)" - console.log "\n\n\n=-=-=[CONNECT.SET]", sql, "\n\n\n" #xxx - @client.query sql, [sid, ttl, json], (err) => - console.log "SET!", err, sid, ttl, json - return fn err if err? + # -- Update session if exists + @client.query "UPDATE `sessions`.`session` SET `ttl`=?, `json`=? WHERE `sid`=?", [ttl, json, sid], (err1, meta) => + console.log "\n\n\n=-=-=[SET](2)", err1, meta, "\n\n\n" #xxx + return fn err1 if err1? + return fn.apply(this, arguments) if meta.affectedRows >= 1 + # -- Create new session (because doesn't exist) + console.log "\n\n\n=-=-=[SET](2.1)", err1, "\n\n\n" #xxx + sql = "INSERT INTO `sessions`.`session` (`sid`, `ttl`, `json`) VALUES (?, ?, ?)" + @client.query sql, [sid, ttl, json], (err2) => + console.log "\n\n\n=-=-=[CONNECT.SET]", sql, err2, sid, ttl, json, "\n\n\n" #xxx + return fn err2 if err2? fn.apply(this, arguments) From 873f1c957bd1af822283e395c111df9ee284f28e Mon Sep 17 00:00:00 2001 From: Jonathan Friedman Date: Sun, 15 Sep 2013 20:52:51 -0700 Subject: [PATCH 13/21] feature: limitations section in docs --- README.markdown | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.markdown b/README.markdown index b443f08..97f5ad3 100644 --- a/README.markdown +++ b/README.markdown @@ -41,6 +41,13 @@ As [this study](http://www.aspieschool.com/wiki/index.php?title=Redis_vs_MySQL_( MySQL's Memory Engine can performed sustained writes at 92% the speed of Redis, yet performs reads at almost 25X (times!!!) faster. Given that session stores show a heavy read bias, the end result is a large performance gain. +Limitations +----------- + +In general, if you follow best-practices for session storage you won't have problems, but MySQL's memory engine gains performance through limiting what and how you can store data. + +* Session data must be JSON serializable (no binary objects) +* Maximum serialized session size is 4096 bytes (chosen for practicality/performance; hard coded, not MySQL restriction) Installation ------------ From 1defd71837f6a11b4a7ac0fabba36b6419a065d5 Mon Sep 17 00:00:00 2001 From: Jonathan Friedman Date: Sun, 15 Sep 2013 20:54:23 -0700 Subject: [PATCH 14/21] cleanup: docs formatting --- README.markdown | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.markdown b/README.markdown index 97f5ad3..53ed1d8 100644 --- a/README.markdown +++ b/README.markdown @@ -14,7 +14,7 @@ Optimizations/Deltas Why MySQL for Sessions? ------------------------ -## Less Maintenance +### Less Maintenance * If you already use MySQL for your primary data store, eliminating the use of Mongo or Redis reduces the number of vendors, number of critical failure points, and probability of failure in your system as a whole. @@ -24,7 +24,7 @@ Why MySQL for Sessions? * Upgrades to your primary datastore automatically effect the session store. You don't need to perform two separate upgrades. -## Lower Operating Costs +### Lower Operating Costs * It is less expensive to scale existing technology (provision a larger database server), than to provision multiple smaller database servers @@ -33,7 +33,7 @@ Why MySQL for Sessions? * Fewer languages means less development time and fewer management and monitoring tools to buy. You are already monitoring your primary data store, why not just reuse that investment. -## Better performance +### Better performance MySQL in-memory table stores are about as efficient as data storage can get, primary due to its lack of features. Data is allocated in small blocks and indexed with a hash or binary tree. From d4a0df6149dadbb603b860450d3ed30fcae2d3ef Mon Sep 17 00:00:00 2001 From: Jonathan Friedman Date: Sun, 15 Sep 2013 20:56:39 -0700 Subject: [PATCH 15/21] cleanup: fix link --- README.markdown | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.markdown b/README.markdown index 53ed1d8..f8b615c 100644 --- a/README.markdown +++ b/README.markdown @@ -37,7 +37,7 @@ Why MySQL for Sessions? MySQL in-memory table stores are about as efficient as data storage can get, primary due to its lack of features. Data is allocated in small blocks and indexed with a hash or binary tree. -As [this study](http://www.aspieschool.com/wiki/index.php?title=Redis_vs_MySQL_(Benchmarks)) revealed, +As [this study](http://bit.ly/17ZzafB) revealed, MySQL's Memory Engine can performed sustained writes at 92% the speed of Redis, yet performs reads at almost 25X (times!!!) faster. Given that session stores show a heavy read bias, the end result is a large performance gain. From dafc2551b904b45c32ef673c59e7253cf5468637 Mon Sep 17 00:00:00 2001 From: Jonathan Friedman Date: Sun, 15 Sep 2013 21:54:17 -0700 Subject: [PATCH 16/21] dev: downgrade from memory engine to support larger session sizes --- README.markdown | 4 +++- lib/connect-mysql-session.coffee | 17 ++++++----------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/README.markdown b/README.markdown index f8b615c..454ec71 100644 --- a/README.markdown +++ b/README.markdown @@ -46,8 +46,10 @@ Limitations In general, if you follow best-practices for session storage you won't have problems, but MySQL's memory engine gains performance through limiting what and how you can store data. +* MySQL version >= 5.0.3 with Memory Engine is required +* Node.js version >= 0.8 * Session data must be JSON serializable (no binary objects) -* Maximum serialized session size is 4096 bytes (chosen for practicality/performance; hard coded, not MySQL restriction) +* Maximum serialized session size is 64k bytes (MySQL Memory Engine restriction resulting from row-size limit) Installation ------------ diff --git a/lib/connect-mysql-session.coffee b/lib/connect-mysql-session.coffee index db1af2a..cdb832f 100644 --- a/lib/connect-mysql-session.coffee +++ b/lib/connect-mysql-session.coffee @@ -35,7 +35,6 @@ module.exports = (connect) -> initialize: (fn) => return fn() if @initialized #run only once - console.log "DATABASE!", @initialized @client.connect() sql = """ CREATE DATABASE IF NOT EXISTS `sessions` @@ -48,10 +47,12 @@ module.exports = (connect) -> CREATE TABLE IF NOT EXISTS `sessions`.`session` ( `sid` varchar(40) NOT NULL DEFAULT '', `ttl` int(11) DEFAULT NULL, - `json` varchar(4096) DEFAULT '', + `json` mediumtext DEFAULT NULL, + `createdAt` datetime DEFAULT NULL, + `updatedAt` datetime DEFAULT NULL, PRIMARY KEY (`sid`) ) - ENGINE=MEMORY + ENGINE=INNODB DEFAULT CHARSET=utf8 """ @client.query sql, (err, rows, fields) => @@ -64,12 +65,10 @@ module.exports = (connect) -> get: (sid, fn) => - console.log "GET", sid @initialize (error) => return fn error if error? @client.query "SELECT * FROM `sessions`.`session` WHERE `sid`=?", [sid], (err, rows, fields) => return fn err if err? - console.log "GOT", rows[0] result = undefined try result = JSON.parse rows[0].json if rows?[0]? @@ -83,20 +82,16 @@ module.exports = (connect) -> ttl = @options.ttl json = JSON.stringify(session) ttl = ttl or ((if "number" is typeof maxAge then maxAge / 1000 | 0 else @options.defaultExpiration)) - console.log "SET", sid, ttl, json @initialize (error) => - console.log "\n\n\n=-=-=[SET](1)", error, "\n\n\n" #xxx return fn error if error? # -- Update session if exists - @client.query "UPDATE `sessions`.`session` SET `ttl`=?, `json`=? WHERE `sid`=?", [ttl, json, sid], (err1, meta) => + @client.query "UPDATE `sessions`.`session` SET `ttl`=?, `json`=?, `updatedAt`=UTC_TIMESTAMP() WHERE `sid`=?", [ttl, json, sid], (err1, meta) => console.log "\n\n\n=-=-=[SET](2)", err1, meta, "\n\n\n" #xxx return fn err1 if err1? return fn.apply(this, arguments) if meta.affectedRows >= 1 # -- Create new session (because doesn't exist) - console.log "\n\n\n=-=-=[SET](2.1)", err1, "\n\n\n" #xxx - sql = "INSERT INTO `sessions`.`session` (`sid`, `ttl`, `json`) VALUES (?, ?, ?)" + sql = "INSERT INTO `sessions`.`session` (`sid`, `ttl`, `json`, `createdAt`, `updatedAt`) VALUES (?, ?, ?, UTC_TIMESTAMP(), UTC_TIMESTAMP())" @client.query sql, [sid, ttl, json], (err2) => - console.log "\n\n\n=-=-=[CONNECT.SET]", sql, err2, sid, ttl, json, "\n\n\n" #xxx return fn err2 if err2? fn.apply(this, arguments) From 100cb0bc584312162eab72a5eb2a4ad5912f91dc Mon Sep 17 00:00:00 2001 From: Jonathan Friedman Date: Mon, 16 Sep 2013 03:07:24 -0700 Subject: [PATCH 17/21] feature: session expiration timer --- lib/connect-mysql-session.coffee | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/lib/connect-mysql-session.coffee b/lib/connect-mysql-session.coffee index cdb832f..c7d70ee 100644 --- a/lib/connect-mysql-session.coffee +++ b/lib/connect-mysql-session.coffee @@ -9,8 +9,7 @@ module.exports = (connect) -> password: login password checkExpirationInterval: (in seconds) defaultExpiration: (in seconds) - client: (optional) fully instantiated client to use, instead of creating one internally - ttl: no idea yet... + client: (optional) fully instantiated client to use, instead of creating one internally ### class MySqlStore extends connect.session.Store @@ -22,7 +21,7 @@ module.exports = (connect) -> @options.host ?= "127.0.0.1" @options.user ?= "root" @options.password ?= "" - @options.checkExpirationInterval ?= 24*60*60 #check once a day + @options.checkExpirationInterval ?= 12*60*60 #check twice a day @options.defaultExpiration ?= 7*24*60*60 #expire after one week # -- Link middleware connect.session.Store.call this, @options @@ -61,9 +60,19 @@ module.exports = (connect) -> return fn err console.log "MySQL session store initialized." @initialized = true + @_watchdog() #expire expired sessions fn() - + _watchdog: () => + sql = """ + DELETE FROM `sessions`.`session` WHERE TIME_TO_SEC(UTC_TIMESTAMP()) - TIME_TO_SEC(`updatedAt`) > `ttl` + """ + @client.query sql, [], (err, meta) => + console.log "Could not cleanup expired sessions:", err if err? + console.log "Removed #{meta.affectedRows} expired user sessions." if meta.affectedRows > 0 + setTimeout @_watchdog, @options.checkExpirationInterval * 1000 + + get: (sid, fn) => @initialize (error) => return fn error if error? @@ -86,7 +95,6 @@ module.exports = (connect) -> return fn error if error? # -- Update session if exists @client.query "UPDATE `sessions`.`session` SET `ttl`=?, `json`=?, `updatedAt`=UTC_TIMESTAMP() WHERE `sid`=?", [ttl, json, sid], (err1, meta) => - console.log "\n\n\n=-=-=[SET](2)", err1, meta, "\n\n\n" #xxx return fn err1 if err1? return fn.apply(this, arguments) if meta.affectedRows >= 1 # -- Create new session (because doesn't exist) From 414a8504542e07b5759ed528b3717d49bba085de Mon Sep 17 00:00:00 2001 From: Jonathan Friedman Date: Mon, 16 Sep 2013 03:19:46 -0700 Subject: [PATCH 18/21] cleanup: update documentation --- README.markdown | 44 ++++++++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/README.markdown b/README.markdown index 454ec71..4448a75 100644 --- a/README.markdown +++ b/README.markdown @@ -9,7 +9,7 @@ Optimizations/Deltas -------------------- * Now written (and maintained) in Coffeescript * Removed unnecessary dependence on Sequelize -* Switched to mySql's in-memory database engine +* Forward support for mySql's in-memory database engine Why MySQL for Sessions? ------------------------ @@ -33,23 +33,42 @@ Why MySQL for Sessions? * Fewer languages means less development time and fewer management and monitoring tools to buy. You are already monitoring your primary data store, why not just reuse that investment. -### Better performance +### Better performance? -MySQL in-memory table stores are about as efficient as data storage can get, primary due to its lack of features. Data is allocated in small blocks and indexed with a hash or binary tree. +Sessions are the simplest case of table storage using no relations and single primary key btree or hash indexes. This largely mitigates the disadvantages of relational database overhead (conversely mitigating most of the advantages of dictionary stores that are essentially the same thing as flat tables with single indexes). + +By default this library uses the InnoDB persistent storage engine in MySQL to allow for up to 16MB of data to be stored in each user session and to do so with dynamic memory allocation. InnoDB is only about 2%-8% slower than a similarly provisioned Redis instance. + +If greater performance is desired, you can switch to the MySQL Memory engine with a one word change to the code (will eventually be a direct config option in this library). MySQL in-memory table stores are about as efficient as data storage can get, primary due to its lack of features. The entire table is statically allocated with data allocated in small blocks within it and indexed with a hash or binary tree. As [this study](http://bit.ly/17ZzafB) revealed, -MySQL's Memory Engine can performed sustained writes at 92% the speed of Redis, yet performs reads at almost 25X (times!!!) faster. Given that session stores show a heavy read bias, the end result is a large performance gain. +MySQL's Memory Engine performed sustained writes at 92% the speed of Redis, yet performed reads at almost 25X (times!!!) the speed. Given that session stores show a heavy read bias, the end result is a large performance gain. Limitations ----------- -In general, if you follow best-practices for session storage you won't have problems, but MySQL's memory engine gains performance through limiting what and how you can store data. +### General + +These limitations apply regardless of the database engine chosen: * MySQL version >= 5.0.3 with Memory Engine is required * Node.js version >= 0.8 * Session data must be JSON serializable (no binary objects) -* Maximum serialized session size is 64k bytes (MySQL Memory Engine restriction resulting from row-size limit) + +### Memory Engine + +In general, if you follow best-practices for session storage you won't have problems, but MySQL's memory engine gains performance through limiting what and how you can store data. + +* Maximum serialized session size is 20k bytes (MySQL Memory Engine restriction resulting from row-size limit) +* Memory allocated to the engine is not available to cache primary tables and can hurt performance if too large. + +### InnoDB Engine + +If you use the InnoDB engine (default): + +* Maximum serialized session size is 16MB bytes + Installation ------------ @@ -75,26 +94,27 @@ The following example uses [expressjs][], but this should work fine using [conne Options ------- -### forceSync ### +### host, user, password ### -Default: `false`. If set to true, the Sessions table will be dropped before being reinitialized, effectively clearing all session data. +Database credentials. Defaults to localhost defaults. ### checkExpirationInterval ### -Default: `1000*60*10` (10 minutes). How frequently the session store checks for and clears expired sessions. +Default: `12*60*60` (Twice a day). Specified in seconds. How frequently the session store checks for and clears expired sessions. ### defaultExpiration ### -Default: `1000*60*60*24` (1 day). How long session data is stored for "user session" cookies -- i.e. sessions that only last as long as the user keeps their browser open, which are created by doing `req.session.maxAge = null`. +Default: `7*24*60*60` (1 week). Specified in seconds. How long session data is stored for "user session" cookies -- i.e. sessions that only last as long as the user keeps their browser open, which are created by doing `req.session.maxAge = null`. Changes ------- -### 0.2.0 (2013-09-14) +### 0.2.6 (2013-09-14) * Switch to Coffeescript * Removed Sequelize -* Built on memory engine (MUCH more performant) +* Built on InnoDB engine (MUCH more space performant) +* Built on memory engine (MUCH more time performant) ### 0.1.1 and 0.1.2 (2011-08-03) ### From 7023500442e28eff6a848075de940b749c6f3931 Mon Sep 17 00:00:00 2001 From: Jonathan Friedman Date: Mon, 16 Sep 2013 03:22:41 -0700 Subject: [PATCH 19/21] cleanup: more doc updates --- README.markdown | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.markdown b/README.markdown index 4448a75..9fb7ba0 100644 --- a/README.markdown +++ b/README.markdown @@ -84,8 +84,12 @@ The following example uses [expressjs][], but this should work fine using [conne var app = express.createServer(); app.use(express.cookieParser()); app.use(express.session({ - store: new MySQLSessionStore("dbname", "user", "password", { - // options... + store: new MySQLSessionStore({ + host: 127.0.0.1, //database host name + user: "root", //database username + password: "", //database user's password + checkExpirationInterval: 12*60*60, //how frequently to check for dead sessions (seconds) + defaultExpiration: 7*24*60*60 //how long to keep session alive (seconds) }), secret: "keyboard cat" })); From d45d0ce80199850ade5c597fc14587de57530444 Mon Sep 17 00:00:00 2001 From: Jonathan Friedman Date: Mon, 16 Sep 2013 09:10:17 -0700 Subject: [PATCH 20/21] bugfix: make storage process atomic --- lib/connect-mysql-session.coffee | 18 +++++++++--------- package.json | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/connect-mysql-session.coffee b/lib/connect-mysql-session.coffee index c7d70ee..4d64d9e 100644 --- a/lib/connect-mysql-session.coffee +++ b/lib/connect-mysql-session.coffee @@ -93,15 +93,15 @@ module.exports = (connect) -> ttl = ttl or ((if "number" is typeof maxAge then maxAge / 1000 | 0 else @options.defaultExpiration)) @initialize (error) => return fn error if error? - # -- Update session if exists - @client.query "UPDATE `sessions`.`session` SET `ttl`=?, `json`=?, `updatedAt`=UTC_TIMESTAMP() WHERE `sid`=?", [ttl, json, sid], (err1, meta) => - return fn err1 if err1? - return fn.apply(this, arguments) if meta.affectedRows >= 1 - # -- Create new session (because doesn't exist) - sql = "INSERT INTO `sessions`.`session` (`sid`, `ttl`, `json`, `createdAt`, `updatedAt`) VALUES (?, ?, ?, UTC_TIMESTAMP(), UTC_TIMESTAMP())" - @client.query sql, [sid, ttl, json], (err2) => - return fn err2 if err2? - fn.apply(this, arguments) + # -- Update session if exists; Create otherwise + sql = """ + INSERT INTO `sessions`.`session` (`sid`, `ttl`, `json`, `createdAt`, `updatedAt`) + VALUES (?, ?, ?, UTC_TIMESTAMP(), UTC_TIMESTAMP()) + ON DUPLICATE KEY UPDATE + """ + @client.query sql, [sid, ttl, json], (err) => + return fn err if err? + fn.apply(this, arguments) destroy: (sid, fn) => diff --git a/package.json b/package.json index fe85e5e..4f14591 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Daniel Dickison ", "name": "connect-mysql-session", "description": "A MySQL session store for node.js connect.", - "version": "0.2.6", + "version": "0.2.7", "repository": { "type": "git", "url": "git://github.com/CarnegieLearning/connect-mysql-session.git" From 8ea6693bb37fe12c58498f695180ee918df8020e Mon Sep 17 00:00:00 2001 From: Jonathan Friedman Date: Mon, 16 Sep 2013 09:19:07 -0700 Subject: [PATCH 21/21] bugfix: corrected set query --- lib/connect-mysql-session.coffee | 5 +++-- package.json | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/connect-mysql-session.coffee b/lib/connect-mysql-session.coffee index 4d64d9e..2d5271f 100644 --- a/lib/connect-mysql-session.coffee +++ b/lib/connect-mysql-session.coffee @@ -97,9 +97,10 @@ module.exports = (connect) -> sql = """ INSERT INTO `sessions`.`session` (`sid`, `ttl`, `json`, `createdAt`, `updatedAt`) VALUES (?, ?, ?, UTC_TIMESTAMP(), UTC_TIMESTAMP()) - ON DUPLICATE KEY UPDATE + ON DUPLICATE KEY + UPDATE `ttl`=?, `json`=?, `updatedAt`=UTC_TIMESTAMP() """ - @client.query sql, [sid, ttl, json], (err) => + @client.query sql, [sid, ttl, json, ttl, json], (err) => return fn err if err? fn.apply(this, arguments) diff --git a/package.json b/package.json index 4f14591..53728db 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Daniel Dickison ", "name": "connect-mysql-session", "description": "A MySQL session store for node.js connect.", - "version": "0.2.7", + "version": "0.2.8", "repository": { "type": "git", "url": "git://github.com/CarnegieLearning/connect-mysql-session.git"