SessionStore: Delete DB record when session expires

This only deletes records known to the current Etherpad instance --
old records from previous runs are not automatically cleaned up.
This commit is contained in:
Richard Hansen 2021-12-23 03:34:52 -05:00
parent 72cd983f0f
commit 945e6848e2
4 changed files with 91 additions and 29 deletions

View file

@ -26,11 +26,17 @@ class SessionStore extends Store {
// - `db`: Session expiration as recorded in the database (ms since epoch, not a Date).
// - `real`: Actual session expiration (ms since epoch, not a Date). Always greater than or
// equal to `db`.
// - `timeout`: Timeout ID for a timeout that will clean up the database record.
this._expirations = new Map();
}
shutdown() {
for (const {timeout} of this._expirations.values()) clearTimeout(timeout);
}
async _updateExpirations(sid, sess, updateDbExp = true) {
const exp = this._expirations.get(sid) || {};
clearTimeout(exp.timeout);
const {cookie: {expires} = {}} = sess || {};
if (expires) {
const sessExp = new Date(expires).getTime();
@ -41,6 +47,15 @@ class SessionStore extends Store {
// If reading from the database, update the expiration with the latest value from touch() so
// that touch() appears to write to the database every time even though it doesn't.
if (typeof expires === 'string') sess.cookie.expires = new Date(exp.real).toJSON();
// Use this._get(), not this._destroy(), to destroy the DB record for the expired session.
// This is done in case multiple Etherpad instances are sharing the same database and users
// are bouncing between the instances. By using this._get(), this instance will query the DB
// for the latest expiration time written by any of the instances, ensuring that the record
// isn't prematurely deleted if the expiration time was updated by a different Etherpad
// instance. (Important caveat: Client-side database caching, which ueberdb does by default,
// could still cause the record to be prematurely deleted because this instance might get a
// stale expiration time from cache.)
exp.timeout = setTimeout(() => this._get(sid), exp.real - now);
this._expirations.set(sid, exp);
} else {
this._expirations.delete(sid);
@ -66,6 +81,7 @@ class SessionStore extends Store {
async _destroy(sid) {
logger.debug(`DESTROY ${sid}`);
clearTimeout((this._expirations.get(sid) || {}).timeout);
this._expirations.delete(sid);
await DB.remove(`sessionstorage:${sid}`);
}

View file

@ -16,6 +16,7 @@ const webaccess = require('./express/webaccess');
const logger = log4js.getLogger('http');
let serverName;
let sessionStore;
const sockets = new Set();
const socketsEvents = new events.EventEmitter();
const startTime = stats.settableGauge('httpStartTime');
@ -23,32 +24,35 @@ const startTime = stats.settableGauge('httpStartTime');
exports.server = null;
const closeServer = async () => {
if (exports.server == null) return;
logger.info('Closing HTTP server...');
// Call exports.server.close() to reject new connections but don't await just yet because the
// Promise won't resolve until all preexisting connections are closed.
const p = util.promisify(exports.server.close.bind(exports.server))();
await hooks.aCallAll('expressCloseServer');
// Give existing connections some time to close on their own before forcibly terminating. The time
// should be long enough to avoid interrupting most preexisting transmissions but short enough to
// avoid a noticeable outage.
const timeout = setTimeout(async () => {
logger.info(`Forcibly terminating remaining ${sockets.size} HTTP connections...`);
for (const socket of sockets) socket.destroy(new Error('HTTP server is closing'));
}, 5000);
let lastLogged = 0;
while (sockets.size > 0) {
if (Date.now() - lastLogged > 1000) { // Rate limit to avoid filling logs.
logger.info(`Waiting for ${sockets.size} HTTP clients to disconnect...`);
lastLogged = Date.now();
if (exports.server != null) {
logger.info('Closing HTTP server...');
// Call exports.server.close() to reject new connections but don't await just yet because the
// Promise won't resolve until all preexisting connections are closed.
const p = util.promisify(exports.server.close.bind(exports.server))();
await hooks.aCallAll('expressCloseServer');
// Give existing connections some time to close on their own before forcibly terminating. The
// time should be long enough to avoid interrupting most preexisting transmissions but short
// enough to avoid a noticeable outage.
const timeout = setTimeout(async () => {
logger.info(`Forcibly terminating remaining ${sockets.size} HTTP connections...`);
for (const socket of sockets) socket.destroy(new Error('HTTP server is closing'));
}, 5000);
let lastLogged = 0;
while (sockets.size > 0) {
if (Date.now() - lastLogged > 1000) { // Rate limit to avoid filling logs.
logger.info(`Waiting for ${sockets.size} HTTP clients to disconnect...`);
lastLogged = Date.now();
}
await events.once(socketsEvents, 'updated');
}
await events.once(socketsEvents, 'updated');
await p;
clearTimeout(timeout);
exports.server = null;
startTime.setValue(0);
logger.info('HTTP server closed');
}
await p;
clearTimeout(timeout);
exports.server = null;
startTime.setValue(0);
logger.info('HTTP server closed');
if (sessionStore) sessionStore.shutdown();
sessionStore = null;
};
exports.createServer = async () => {
@ -172,9 +176,10 @@ exports.restartServer = async () => {
app.use(cookieParser(settings.sessionKey, {}));
sessionStore = new SessionStore();
exports.sessionMiddleware = expressSession({
secret: settings.sessionKey,
store: new SessionStore(),
store: sessionStore,
resave: false,
saveUninitialized: true,
// Set the cookie name to a javascript identifier compatible string. Makes code handling it