Skip to content
This repository has been archived by the owner on Oct 25, 2023. It is now read-only.

Commit

Permalink
feat: add getLockFileGuard method to util
Browse files Browse the repository at this point in the history
  • Loading branch information
jlipps committed Apr 23, 2020
1 parent 0e7008a commit 4d0c059
Show file tree
Hide file tree
Showing 4 changed files with 141 additions and 2 deletions.
1 change: 1 addition & 0 deletions lib/fs.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ let fs = {
const simples = [
'open', 'close', 'access', 'readFile', 'writeFile', 'write', 'read',
'readlink', 'chmod', 'unlink', 'readdir', 'stat', 'rename', 'lstat',
'appendFile'
];
for (const s of simples) {
fs[s] = B.promisify(_fs[s]);
Expand Down
48 changes: 47 additions & 1 deletion lib/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
v1 as uuidV1, v3 as uuidV3,
v4 as uuidV4, v5 as uuidV5
} from 'uuid';
import _lockfile from 'lockfile';


const W3C_WEB_ELEMENT_IDENTIFIER = 'element-6066-11e4-a52e-4f735466cecf';
Expand Down Expand Up @@ -443,11 +444,56 @@ async function toInMemoryBase64 (srcPath, opts = {}) {
return Buffer.concat(resultBuffers);
}

/**
* @typedef {Object} LockFileOptions
* @param {number} timeout [120] The max time in seconds to wait for the lock
*/
/**
* Create an async function which, when called, will not proceed until a certain file is no
* longer present on the system. This allows for preventing concurrent behavior across processes
* using a known lockfile path.
*
* @param {string} lockFile The full path to the file used for the lock
* @param {LockFileOptions} opts
* @returns {AsyncFunction} async function that takes another async function defining the locked
* behavior
*/
function getLockFileGuard (lockFile, opts = {timeout: 120}) {
const lock = B.promisify(_lockfile.lock);
const check = B.promisify(_lockfile.check);
const unlock = B.promisify(_lockfile.unlock);

const guard = async (behavior) => {
try {
// if the lockfile doesn't exist, lock it synchronously to make sure no other call
// on the same spin of the event loop can also initiate a lock. If the lockfile does exist
// then just use the regular async 'lock' method which will wait on the lock.
if (!_lockfile.checkSync(lockFile)) {
_lockfile.lockSync(lockFile);
} else {
await lock(lockFile, {wait: opts.timeout * 1000});
}
} catch (e) {
throw new Error(`Could not acquire lock on ${lockFile}. Original error: ${e}`);
}
try {
return await behavior();
} finally {
// whether the behavior succeeded or not, get rid of the lock
await unlock(lockFile);
}
};

guard.check = async () => await check(lockFile);

return guard;
}

export {
hasValue, escapeSpace, escapeSpecialChars, localIp, cancellableDelay,
multiResolve, safeJsonParse, wrapElement, unwrapElement, filterObject,
toReadableSizeString, isSubPath, W3C_WEB_ELEMENT_IDENTIFIER,
isSameDestination, compareVersions, coerceVersion, quote, unleakString,
jsonStringify, pluralize, GiB, MiB, KiB, toInMemoryBase64,
uuidV1, uuidV3, uuidV4, uuidV5, shellParse,
uuidV1, uuidV3, uuidV4, uuidV5, shellParse, getLockFileGuard
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"jimp": "^0.10.0",
"jsftp": "^2.1.2",
"klaw": "^3.0.0",
"lockfile": "^1.0.4",
"lodash": "^4.2.1",
"md5-file": "^4.0.0",
"mjpeg-server": "^0.3.0",
Expand Down
93 changes: 92 additions & 1 deletion test/util-e2e-specs.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import B from 'bluebird';
import chai from 'chai';
import chaiAsPromised from 'chai-as-promised';
import path from 'path';
import * as util from '../lib/util';
import { tempDir, fs } from '../index';


chai.should();
chai.use(chaiAsPromised);

describe('#util', function () {
Expand Down Expand Up @@ -33,4 +34,94 @@ describe('#util', function () {
});
});

describe('getLockFileGuard()', function () {
let tmpRoot;
let lockFile;
let testFile;

async function guardedBehavior (text, msBeforeActing) {
await B.delay(msBeforeActing);
await fs.appendFile(testFile, text, 'utf8');
return text;
}

async function testFileContents () {
return (await fs.readFile(testFile)).toString('utf8');
}

beforeEach(async function () {
tmpRoot = await tempDir.openDir();
lockFile = path.resolve(tmpRoot, 'test.lock');
testFile = path.resolve(tmpRoot, 'test');
await fs.writeFile(testFile, 'a', 'utf8');
});

afterEach(async function () {
try {
await fs.unlink(lockFile);
await fs.unlink(testFile);
} catch (ign) {}
});

it('should lock a file during the given behavior', async function () {
const guard = util.getLockFileGuard(lockFile);
await guard.check().should.eventually.be.false;
const guardPromise = guard(async () => await guardedBehavior('b', 500));
await B.delay(200);
await guard.check().should.eventually.be.true;
await guardPromise;
await guard.check().should.eventually.be.false;
await testFileContents().should.eventually.eql('ab');
});

it('should block other behavior until the lock is released', async function () {
// first prove that without a lock, we get races
await testFileContents().should.eventually.eql('a');
const unguardedPromise1 = guardedBehavior('b', 500);
const unguardedPromise2 = guardedBehavior('c', 100);
await unguardedPromise1;
await unguardedPromise2;
await testFileContents().should.eventually.eql('acb');

// now prove that with a lock, we don't get any interlopers
const guard = util.getLockFileGuard(lockFile);
const guardPromise1 = guard(async () => await guardedBehavior('b', 500));
const guardPromise2 = guard(async () => await guardedBehavior('c', 100));
await guardPromise1;
await guardPromise2;
await testFileContents().should.eventually.eql('acbbc');
});

it('should return the result of the guarded behavior', async function () {
const guard = util.getLockFileGuard(lockFile);
const guardPromise1 = guard(async () => await guardedBehavior('hello', 500));
const guardPromise2 = guard(async () => await guardedBehavior('world', 100));
const ret1 = await guardPromise1;
const ret2 = await guardPromise2;
ret1.should.eql('hello');
ret2.should.eql('world');
});

it('should time out if the lock is not released', async function () {
this.timeout(5000);
const guard = util.getLockFileGuard(lockFile, {timeout: 0.5});
const p1 = guard(async () => await guardedBehavior('hello', 1200));
const p2 = guard(async () => await guardedBehavior('world', 10));
await p2.should.eventually.be.rejectedWith(/not acquire lock/);
await p1.should.eventually.eql('hello');
});

it('should still release lock if guarded behavior fails', async function () {
this.timeout(5000);
const guard = util.getLockFileGuard(lockFile);
const p1 = guard(async () => {
await B.delay(500);
throw new Error('bad');
});
const p2 = guard(async () => await guardedBehavior('world', 100));
await p1.should.eventually.be.rejectedWith(/bad/);
await p2.should.eventually.eql('world');
});
});

});

0 comments on commit 4d0c059

Please sign in to comment.