diff --git a/package.json b/package.json index 66a64a4..a33e30b 100644 --- a/package.json +++ b/package.json @@ -31,13 +31,13 @@ "devDependencies": { "@commitlint/cli": "^18.2.0", "@commitlint/config-conventional": "^11.0.0", - "biolink-model": "workspace:../biolink-model", "@types/debug": "^4.1.10", "@types/jest": "^29.5.7", "@types/lodash": "^4.14.200", "@types/node": "^20.8.10", "@typescript-eslint/eslint-plugin": "^6.8.0", "@typescript-eslint/parser": "^6.8.0", + "biolink-model": "workspace:../biolink-model", "eslint": "^8.53.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.1", @@ -59,6 +59,7 @@ "ioredis": "^5.3.2", "ioredis-mock": "^8.9.0", "lodash": "^4.17.21", + "proper-lockfile": "^4.1.2", "redlock": "5.0.0-beta.2" } } diff --git a/src/misc.ts b/src/misc.ts index b615233..29e2290 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -1,3 +1,6 @@ +import lockfile from "proper-lockfile"; +import { setTimeout as sleep } from "timers/promises"; + export function toArray(input: Type | Type[]): Type[] { if (Array.isArray(input)) { return input; @@ -76,3 +79,62 @@ export function timeoutPromise(promise: Promise, timeout: number): Promise reject = newReject; }); } + +export const LOCKFILE_STALENESS = {stale: 5000}; // lock expiration in milliseconds to prevent deadlocks +export const LOCKFILE_RETRY_CONFIG = { + retries: { + retries: 10, + factor: 2, + minTimeout: 100, + maxTimeout: 1000, + }, + stale: LOCKFILE_STALENESS["stale"], +}; + +export async function lockWithActionAsync(filePath: string, action: () => Promise, debug?: (message: string) => void): Promise { + if (process.env.NODE_ENV !== "production") { + debug(`Development mode: Skipping lockfile ${process.env.NODE_ENV}`); + const result = await action(); + return result; + } + + let release; + try { + release = await lockfile.lock(filePath, LOCKFILE_RETRY_CONFIG); + const result = await action(); + return result; + } catch (error) { + debug(`Lockfile error: ${error}`); + // throw error; + } finally { + if (release) release(); + } +} + +export function lockWithActionSync(filePath: string, action: () => T, debug?: (message: string) => void): T { + if (process.env.NODE_ENV !== "production") { + debug(`Development mode: Skipping lockfile ${process.env.NODE_ENV}`); + return action(); + } + + let release; + try { + const startTime = Date.now(); + + while (Date.now() - startTime < LOCKFILE_STALENESS["stale"]) { + if (!lockfile.checkSync(filePath)) { + release = lockfile.lockSync(filePath, LOCKFILE_STALENESS); + const result = action(); + return result; + } else { + sleep(LOCKFILE_RETRY_CONFIG["retries"]["minTimeout"]); + } + } + debug("Lockfile timeout: did not read file"); + } catch (error) { + debug(`Lockfile error: ${error}`); + // throw error; + } finally { + if (release) release(); + } +}