Skip to content

Commit e6a2237

Browse files
committed
feat(lockfile): add custom lockfile implementation
- add custom lockfile implementation - remove dep "lockfile" - add dep "async-mutex"
1 parent e8d2d4b commit e6a2237

File tree

5 files changed

+329
-27
lines changed

5 files changed

+329
-27
lines changed

packages/mongodb-memory-server-core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,13 @@
4747
},
4848
"dependencies": {
4949
"@types/tmp": "^0.2.0",
50+
"async-mutex": "^0.3.0",
5051
"camelcase": "^6.1.0",
5152
"debug": "^4.2.0",
5253
"find-cache-dir": "^3.3.1",
5354
"find-package-json": "^1.2.0",
5455
"get-port": "^5.1.1",
5556
"https-proxy-agent": "^5.0.0",
56-
"lockfile": "^1.0.4",
5757
"md5-file": "^5.0.0",
5858
"mkdirp": "^1.0.4",
5959
"mongodb": "^3.6.2",

packages/mongodb-memory-server-core/src/util/MongoBinary.ts

Lines changed: 4 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { promises as fspromises, constants } from 'fs';
22
import os from 'os';
33
import path from 'path';
4-
import LockFile from 'lockfile';
54
import mkdirp from 'mkdirp';
65
import findCacheDir from 'find-cache-dir';
76
import MongoBinaryDownload from './MongoBinaryDownload';
87
import resolveConfig, { envToBool, ResolveConfigVariables } from './resolveConfig';
98
import debug from 'debug';
109
import { assertion, isNullOrUndefined, pathExists } from './utils';
1110
import { spawnSync } from 'child_process';
11+
import { LockFile } from './lockfile';
1212

1313
const log = debug('MongoMS:MongoBinary');
1414

@@ -59,21 +59,7 @@ export class MongoBinary {
5959
// wait to get a lock
6060
// downloading of binaries may be quite long procedure
6161
// that's why we are using so big wait/stale periods
62-
await new Promise<void>((res, rej) => {
63-
LockFile.lock(
64-
lockfile,
65-
{
66-
wait: 1000 * 120, // 120 seconds
67-
pollPeriod: 100,
68-
stale: 1000 * 110, // 110 seconds
69-
retries: 3,
70-
retryWait: 100,
71-
},
72-
(err: any) => {
73-
return err ? rej(err) : res();
74-
}
75-
);
76-
});
62+
const lock = await LockFile.lock(lockfile);
7763
log('getDownloadPath: Download lock acquired');
7864

7965
// check cache if it got already added to the cache
@@ -91,16 +77,8 @@ export class MongoBinary {
9177

9278
log('getDownloadPath: Removing Download lock');
9379
// remove lock
94-
await new Promise<void>((res) => {
95-
LockFile.unlock(lockfile, (err) => {
96-
log(
97-
err
98-
? `getDownloadPath: Error when removing download lock ${err}`
99-
: `getDownloadPath: Download lock removed`
100-
);
101-
res(); // we don't care if it was successful or not
102-
});
103-
});
80+
await lock.unlock();
81+
log('getDownloadPath: Download lock removed');
10482

10583
const cachePath = this.cache.get(version);
10684
// ensure that "path" exists, so the return type does not change
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import * as lockFile from '../lockfile';
2+
import * as tmp from 'tmp';
3+
import * as path from 'path';
4+
import { pathExists } from '../utils';
5+
import { promises as fspromises } from 'fs';
6+
7+
let tmpDir: tmp.DirResult;
8+
beforeAll(() => {
9+
tmpDir = tmp.dirSync({ prefix: 'reuse-mongo-mem-', unsafeCleanup: true });
10+
});
11+
12+
afterAll(() => {
13+
tmpDir.removeCallback();
14+
});
15+
16+
describe('LockFile', () => {
17+
it('should successfully acquire and release an lock', async () => {
18+
const lockPath = path.resolve(tmpDir.name, 'sucessful.lock');
19+
expect(await pathExists(lockPath)).toBeFalsy();
20+
21+
expect(lockFile.LockFile.files.size).toBe(0);
22+
23+
const lock = await lockFile.LockFile.lock(lockPath);
24+
expect(lock).toBeInstanceOf(lockFile.LockFile);
25+
expect(await pathExists(lockPath)).toBeTruthy();
26+
expect(lockFile.LockFile.files.size).toBe(1);
27+
expect(lockFile.LockFile.files.has(lockPath)).toBeTruthy();
28+
29+
const lockReadout = parseInt((await fspromises.readFile(lockPath)).toString());
30+
expect(lockReadout).toEqual(process.pid);
31+
32+
await lock.unlock();
33+
expect(await pathExists(lockPath)).toBeFalsy();
34+
expect(lockFile.LockFile.files.size).toBe(0);
35+
expect(lockFile.LockFile.files.has(lockPath)).toBeFalsy();
36+
37+
// @ts-expect-error Somehow jest dosnt find the method "checkLock" in types
38+
jest.spyOn(lockFile.LockFile, 'checkLock');
39+
await lock.unlock();
40+
// @ts-expect-error because "checkLock" is protected
41+
expect(lockFile.LockFile.checkLock).not.toBeCalled();
42+
});
43+
44+
it('should successfully acquire lock after another unlocked', async () => {
45+
// @ts-expect-error Somehow jest dosnt find the method "waitForLock" in types
46+
jest.spyOn(lockFile.LockFile, 'waitForLock');
47+
const lockPath = path.resolve(tmpDir.name, 'sucessful_another.lock');
48+
expect(await pathExists(lockPath)).toBeFalsy();
49+
50+
expect(lockFile.LockFile.files.size).toBe(0);
51+
52+
const lock1 = await lockFile.LockFile.lock(lockPath);
53+
expect(lock1).toBeInstanceOf(lockFile.LockFile);
54+
expect(await pathExists(lockPath)).toBeTruthy();
55+
expect(lockFile.LockFile.files.size).toBe(1);
56+
expect(lockFile.LockFile.files.has(lockPath)).toBeTruthy();
57+
expect(lockFile.LockFile.events.listenerCount(lockFile.LockFileEvents.unlock)).toBe(0);
58+
59+
const lock2 = lockFile.LockFile.lock(lockPath);
60+
expect(await pathExists(lockPath)).toBeTruthy();
61+
expect(lockFile.LockFile.files.size).toBe(1);
62+
expect(lockFile.LockFile.files.has(lockPath)).toBeTruthy();
63+
// ensure that "lock2" gets executed as far as possible
64+
await new Promise(async (res) => {
65+
setTimeout(res, 10);
66+
await lock2;
67+
});
68+
// @ts-expect-error because "waitForLock" is protected
69+
expect(lockFile.LockFile.waitForLock).toBeCalled();
70+
expect(lockFile.LockFile.events.listenerCount(lockFile.LockFileEvents.unlock)).toBe(1);
71+
72+
await lock1.unlock();
73+
await new Promise(async (res) => {
74+
setTimeout(res, 10);
75+
await lock2;
76+
});
77+
expect(await pathExists(lockPath)).toBeTruthy();
78+
expect(lockFile.LockFile.files.size).toBe(1);
79+
expect(lockFile.LockFile.files.has(lockPath)).toBeTruthy();
80+
expect(lockFile.LockFile.events.listenerCount(lockFile.LockFileEvents.unlock)).toBe(0);
81+
82+
const lock2return = await lock2;
83+
expect(lock2return).toBeInstanceOf(lockFile.LockFile);
84+
85+
await lock2return.unlock();
86+
expect(await pathExists(lockPath)).toBeFalsy();
87+
expect(lockFile.LockFile.files.size).toBe(0);
88+
expect(lockFile.LockFile.files.has(lockPath)).toBeFalsy();
89+
});
90+
});
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import { EventEmitter } from 'events';
2+
import * as utils from './utils';
3+
import debug from 'debug';
4+
import * as path from 'path';
5+
import mkdirp from 'mkdirp';
6+
import { promises as fspromises } from 'fs';
7+
import { Mutex } from 'async-mutex';
8+
9+
const log = debug('MongoMS:LockFile');
10+
11+
export enum LockFileStatus {
12+
available,
13+
lockedSelf,
14+
lockedDifferent,
15+
}
16+
17+
export enum LockFileEvents {
18+
lock = 'lock',
19+
unlock = 'unlock',
20+
}
21+
22+
interface LockFileEventsClass extends EventEmitter {
23+
// Overwrite EventEmitter's definitions (to provide at least the event names)
24+
emit(event: LockFileEvents, ...args: any[]): boolean;
25+
on(event: LockFileEvents, listener: (...args: any[]) => void): this;
26+
once(event: LockFileEvents, listener: (...args: any[]) => void): this;
27+
}
28+
29+
/** Dummy class for types */
30+
class LockFileEventsClass extends EventEmitter {}
31+
32+
export class LockFile {
33+
/** All Files that are handled by this process */
34+
static files: Set<string> = new Set();
35+
/** Listen for events from this process */
36+
static events: LockFileEventsClass = new LockFileEventsClass();
37+
static mutex: Mutex = new Mutex();
38+
39+
/**
40+
* Acquire an lockfile
41+
* @param file The file to use as the LockFile
42+
*/
43+
static async lock(file: string): Promise<LockFile> {
44+
await utils.ensureAsync();
45+
log(`lock: Locking file "${file}"`);
46+
47+
const useFile = path.resolve(file.trim());
48+
49+
// just to make sure "path" could resolve it to something
50+
utils.assertion(useFile.length > 0, new Error('Provided Path for lock file is length of 0'));
51+
52+
switch (await this.checkLock(useFile)) {
53+
case LockFileStatus.lockedDifferent:
54+
case LockFileStatus.lockedSelf:
55+
return this.waitForLock(useFile);
56+
case LockFileStatus.available:
57+
return this.createLock(useFile);
58+
default:
59+
throw new Error(`Unknown LockFileStatus!`);
60+
}
61+
}
62+
63+
/**
64+
* Check the status of the lockfile
65+
* @param file The file to use as the LockFile
66+
*/
67+
protected static async checkLock(file: string): Promise<LockFileStatus> {
68+
log(`checkLock: for file "${file}"`);
69+
70+
// if file / path does not exist, directly acquire lock
71+
if (!(await utils.pathExists(file))) {
72+
return LockFileStatus.available;
73+
}
74+
75+
const readout = parseInt((await fspromises.readFile(file)).toString().trim());
76+
77+
if (readout === process.pid) {
78+
log('checkLock: Lock File Already exists, and is for *this* process');
79+
80+
return !this.files.has(file) ? LockFileStatus.available : LockFileStatus.lockedSelf;
81+
}
82+
83+
return utils.isAlive(readout) ? LockFileStatus.lockedDifferent : LockFileStatus.available;
84+
}
85+
86+
/**
87+
* Wait for the Lock file to become available
88+
* @param file The file to use as the LockFile
89+
*/
90+
protected static async waitForLock(file: string): Promise<LockFile> {
91+
log(`waitForLock: Starting to wait for file "${file}"`);
92+
let interval: NodeJS.Timeout | undefined = undefined;
93+
let eventCB: ((val: any) => any) | undefined = undefined;
94+
await new Promise<void>((res) => {
95+
eventCB = (unlockedFile) => {
96+
if (unlockedFile === file) {
97+
res();
98+
}
99+
};
100+
101+
interval = setInterval(async () => {
102+
const lockStatus = await this.checkLock(file);
103+
log(`waitForLock: Interval for file "${file}" with status "${lockStatus}"`);
104+
105+
if (lockStatus === LockFileStatus.available) {
106+
res();
107+
}
108+
}, 1000 * 3); // every 3 seconds
109+
110+
this.events.on(LockFileEvents.unlock, eventCB);
111+
});
112+
113+
if (interval) {
114+
clearInterval(interval);
115+
}
116+
if (eventCB) {
117+
this.events.removeListener(LockFileEvents.unlock, eventCB);
118+
}
119+
120+
log(`waitForLock: File became available "${file}"`);
121+
122+
// i hope the following prevents race-conditions
123+
await utils.ensureAsync(); // to make sure all event listeners got executed
124+
const lockStatus = await this.checkLock(file);
125+
log(`waitForLock: Lock File Status reassessment for file "${file}": ${lockStatus}`);
126+
127+
switch (lockStatus) {
128+
case LockFileStatus.lockedDifferent:
129+
case LockFileStatus.lockedSelf:
130+
return this.waitForLock(file);
131+
case LockFileStatus.available:
132+
return this.createLock(file);
133+
default:
134+
throw new Error(`Unknown LockFileStatus!`);
135+
}
136+
}
137+
138+
/**
139+
* Function create the path and lock file
140+
* @param file The file to use as the LockFile
141+
*/
142+
protected static async createLock(file: string): Promise<LockFile> {
143+
// this function only gets called by processed "file" input, so no re-checking
144+
log(`createLock: Creating lock file "${file}"`);
145+
146+
if (this.files.has(file)) {
147+
log(`createLock: Set already has file "${file}", ignoring`);
148+
}
149+
150+
await this.mutex.runExclusive(async () => {
151+
await mkdirp(path.dirname(file));
152+
153+
await fspromises.writeFile(file, process.pid.toString());
154+
155+
this.files.add(file);
156+
this.events.emit(LockFileEvents.lock, file);
157+
});
158+
159+
log('createLock: Lock File Created');
160+
161+
return new this(file);
162+
}
163+
164+
/** File locked by this instance */
165+
public file?: string;
166+
167+
constructor(file: string) {
168+
this.file = file;
169+
}
170+
171+
/** Unlock the File that is locked by this instance */
172+
async unlock(): Promise<void> {
173+
await utils.ensureAsync();
174+
log(`unlock: Unlocking file "${this.file}"`);
175+
176+
if (utils.isNullOrUndefined(this.file) || this.file?.length <= 0) {
177+
log('unlock: invalid file, returning');
178+
179+
return;
180+
}
181+
182+
switch (await LockFile.checkLock(this.file)) {
183+
case LockFileStatus.available:
184+
log(`unlock: Lock Status was already "available" for file "${this.file}", ignoring`);
185+
await LockFile.mutex.runExclusive(this.unlockCleanup.bind(this, false));
186+
187+
return;
188+
case LockFileStatus.lockedSelf:
189+
await LockFile.mutex.runExclusive(this.unlockCleanup.bind(this, true));
190+
191+
return;
192+
default:
193+
throw new Error(
194+
`Cannot unlock Lock File "${this.file}" because it is not locked by this process!`
195+
);
196+
}
197+
}
198+
199+
/**
200+
* Helper function for the unlock-cleanup
201+
* @param fileio Unlink the file?
202+
*/
203+
protected async unlockCleanup(fileio: boolean = true): Promise<void> {
204+
log(`unlockCleanup: for file "${this.file}"`);
205+
206+
if (utils.isNullOrUndefined(this.file)) {
207+
return;
208+
}
209+
210+
if (fileio) {
211+
await fspromises.unlink(this.file);
212+
}
213+
214+
LockFile.files.delete(this.file);
215+
LockFile.events.emit(LockFileEvents.unlock, this.file);
216+
217+
// making this instance unusable (to prevent double calling)
218+
this.file = undefined;
219+
220+
await utils.ensureAsync();
221+
}
222+
}

0 commit comments

Comments
 (0)