UNPKG

apostrophe

Version:
199 lines (175 loc) • 6.5 kB
const _ = require('lodash'); const Promise = require('bluebird'); module.exports = { options: { alias: 'lock' }, async init(self) { await self.ensureCollection(); }, methods(self) { return { // Obtain a lock with the given name. The lock remains exclusive until // we unlock it (except for certain situations in unusual synchronous // code, see below). // // We MUST release the lock later by calling `unlock` with the same name. // // If the lock is in use by another party, this method will wait until it // is no longer in use, unless `options.wait` is present. If // `options.wait` is explicitly `false`, the method will not wait at all, // and the error reported will be the string `'locked'`. If `options.wait` // is a number, the method will wait that many milliseconds before // reporting the `locked` error. // // The `options` argument can be omitted completely. // // Calling this method when you already have the specified lock will // wait for the previous lock to be released, unless the `waitForSelf` // option is explicitly `false`, in which case an error is thrown. // // SYNCHRONOUS CODE: if you need to go more than 30 seconds without ever // returning to the event loop, set `options.idleTimeout` to a longer // period of time (in milliseconds). This applies only to synchronous // code. (And seriously, why are you running without returning for 5 // minutes in nodejs? Nobody can see your site while you do that.) async lock(name, options) { let lock = null; let when = null; // Implementation notes: since `_id` must be unique, we know // we have the lock if we succeed in inserting a mongodb doc with // an _id equal to the lock name. If we fail due to a duplicate key, // we just keep trying, with exponential backoff but no less than // every 100ms. // // A crashed process should not be allowed to camp on a lock forever, // so we also poll each time to see if the existing lock's `when` stamp // is older than the `idleTimeout`. let retryDelay = 10; const start = Date.now(); options = options || {}; const idleTimeout = options.idleTimeout || 30 * 1000; let wait = Number.MAX_VALUE; if (_.isNumber(options.wait)) { wait = options.wait; } if (options.wait === false) { wait = 0; } self.intervals = self.intervals || {}; if (self.intervals[name]) { if (options.waitForSelf !== false) { return retry(); } else { throw new Error('Attempted to lock ' + name + ' which we have already locked.'); } } return attempt(); async function attempt() { when = Date.now(); try { await fetch(); await timeout(); await insert(); self.intervals[name] = setInterval(refresh, Math.min(idleTimeout / 4, 1000)); } catch (err) { // Only duplicate keys should be retried if (err.code !== 11000) { throw err; } return retry(); } } // We don't trust this for concurrency because it's not atomic. // We just use it to remove old locks if needed async function fetch() { lock = await self.db.findOne({ _id: name }); } async function timeout() { if (!lock) { return; } if (lock.when + lock.idleTimeout >= when) { return; } await self.db.deleteMany({ _id: name, unique: lock.unique }); } async function insert() { await self.db.insertOne({ _id: name, when, idleTimeout, unique: self.apos.util.generateId() }); } async function retry() { if (start + wait < Date.now()) { throw self.apos.error('locked'); } await Promise.delay(retryDelay); // Exponential backoff, but only to a reasonable limit retryDelay *= 2; if (retryDelay > 100) { retryDelay = 100; } return attempt(); } async function refresh() { // For unit testing purposes we can test what happens when // idleTimeout is short and there is no auto-refresh happening if (options.noRefresh) { return; } if (!self.intervals[name]) { return; } try { await self.db.updateOne({ _id: name }, { $set: { when: Date.now() } }); } catch (err) { // Not much more we can do with this error as the // lock method has returned and the operation is underway self.apos.util.error(err); } } }, // Release the given lock name. You must first obtain a lock successfully // via `lock`. Calling this method when you do not already have the lock // will yield an error. async unlock(name) { self.intervals = self.intervals || {}; if (!self.intervals[name]) { throw new Error('Attempted to unlock ' + name + ' which is not locked'); } clearInterval(self.intervals[name]); delete self.intervals[name]; await self.db.deleteMany({ _id: name }); }, // Obtains the named lock, awaits the provided function, // and then releases the lock whether the function throws // an error or not. // // You can think of this as an "upgrade" of your function to // run within a lock in every way. // // The return value of `withLock` will be the value that // `fn` returns. If `fn` throws an error it will be // re-thrown after the lock is safely released. async withLock(name, fn) { let locked = false; try { await self.apos.lock.lock(name); locked = true; return await fn(); } finally { if (locked) { await self.apos.lock.unlock(name); } } }, async ensureCollection() { self.db = await self.apos.db.collection('aposLocks'); } }; } };