From 71c528085c53941ea625c995cee39774ce800f9b Mon Sep 17 00:00:00 2001 From: Francisco Canela Date: Fri, 6 Dec 2024 12:58:26 +0100 Subject: [PATCH] refactor(relayer): extracts lockfile logic and adds tests --- relayer-cli/src/utils/lock.test.ts | 84 ++++++++++++++++++++++++ relayer-cli/src/utils/lock.ts | 85 +++++++++++++++++++++++++ relayer-cli/src/utils/relayerHelpers.ts | 14 +--- 3 files changed, 172 insertions(+), 11 deletions(-) create mode 100644 relayer-cli/src/utils/lock.test.ts create mode 100644 relayer-cli/src/utils/lock.ts diff --git a/relayer-cli/src/utils/lock.test.ts b/relayer-cli/src/utils/lock.test.ts new file mode 100644 index 00000000..fb03845a --- /dev/null +++ b/relayer-cli/src/utils/lock.test.ts @@ -0,0 +1,84 @@ +import { claimLock, getLockFilePath, LockfileExistsError, releaseLock } from "./lock"; + +describe("Lock", () => { + describe("getLockFilePath", () => { + it("should return the lock file path for a given network and chain id", () => { + const network = "mainnet"; + const chainId = 1; + + const result = getLockFilePath(network, chainId); + expect(result).toBe("./state/mainnet_1.pid"); + }); + + it("should ensure the network name is lowercase", () => { + const network = "MAINNET"; + const chainId = 1; + + const result = getLockFilePath(network, chainId); + expect(result).toBe("./state/mainnet_1.pid"); + }); + }); + + describe("claimLock", () => { + const network = "mainnet"; + const chainId = 1; + const expectedLockFilePath = getLockFilePath(network, chainId); + + it("should throw an error if the lockfile already exists", () => { + const deps = { + fileExistsFn: jest.fn().mockReturnValue(true), + }; + + expect(() => claimLock(network, chainId, deps)).toThrow(LockfileExistsError); + }); + + it("should write a file with the PID if none exists", () => { + const deps = { + fileExistsFn: jest.fn().mockReturnValue(false), + writeFileFn: jest.fn(), + }; + + claimLock(network, chainId, deps); + + expect(deps.fileExistsFn).toHaveBeenCalledTimes(1); + expect(deps.writeFileFn).toHaveBeenCalledTimes(1); + + const [path, pid] = deps.writeFileFn.mock.calls[0]; + expect(path).toBe(expectedLockFilePath); + expect(pid).toBe(process.pid.toString()); + }); + }); + + describe("releaseLock", () => { + const network = "mainnet"; + const chainId = 1; + const expectedLockFilePath = getLockFilePath(network, chainId); + + it("should remove the lockfile if it exists", () => { + const deps = { + fileExistsFn: jest.fn().mockReturnValue(true), + unlinkFileFn: jest.fn(), + }; + + releaseLock(network, chainId, deps); + + expect(deps.fileExistsFn).toHaveBeenCalledTimes(1); + expect(deps.unlinkFileFn).toHaveBeenCalledTimes(1); + + const [path] = deps.unlinkFileFn.mock.calls[0]; + expect(path).toBe(expectedLockFilePath); + }); + + it("should do nothing if the file does not exist", () => { + const deps = { + fileExistsFn: jest.fn().mockReturnValue(false), + unlinkFileFn: jest.fn(), + }; + + releaseLock(network, chainId, deps); + + expect(deps.fileExistsFn).toHaveBeenCalledTimes(1); + expect(deps.unlinkFileFn).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/relayer-cli/src/utils/lock.ts b/relayer-cli/src/utils/lock.ts new file mode 100644 index 00000000..9300042f --- /dev/null +++ b/relayer-cli/src/utils/lock.ts @@ -0,0 +1,85 @@ +import fs from "fs"; + +/** + * Returns the lock file path for a given network and chain id + * + * @param network - The network name + * @param chainId - The numerical identifier of the chain + * @returns The lock file path + * + * @example + * getLockFilePath('goerli', 1); // './state/goerli_1.pid' + */ +export function getLockFilePath(network: string, chainId: number) { + return `./state/${network.toLowerCase()}_${chainId}.pid`; +} + +export class LockfileExistsError extends Error { + constructor(path: string) { + super(); + this.message = `The application tried to claim the lockfile ${path} but it already exists. Please ensure no other instance is running and delete the lockfile before starting a new one.`; + this.name = "OnlyOneProcessError"; + } +} + +type ClaimLockDependencies = { + fileExistsFn?: typeof fs.existsSync; + writeFileFn?: typeof fs.writeFileSync; +}; + +/** + * Ensures there is only one process running at the same time for a given lock file. + * + * If the lock file exists, thrown an error. If it does not exists, creates it with the current process id. + * + * @param network - The network name + * @param chain - The chain id + * @param dependencies - FS methods to be used + * + * @example + * claimLock('/opt/app/lock.pid'); + */ +export function claimLock( + network: string, + chain: number, + dependencies: ClaimLockDependencies = { + fileExistsFn: fs.existsSync, + writeFileFn: fs.writeFileSync, + } +) { + const path = getLockFilePath(network, chain); + const { fileExistsFn, writeFileFn } = dependencies; + + if (fileExistsFn(path)) throw new LockfileExistsError(path); + writeFileFn(path, process.pid.toString(), { encoding: "utf8" }); +} + +type ReleaseLockDependencies = { + fileExistsFn?: typeof fs.existsSync; + unlinkFileFn?: typeof fs.unlinkSync; +}; + +/** + * Ensures the lock file is removed + * + * @param network - The network name + * @param chainId - The numerical identifier of the chain + * @param dependencies - FS methods to be used + * + * @example + * releaseLock('/opt/app/lock.pid'); + */ +export function releaseLock( + network: string, + chain: number, + dependencies: ReleaseLockDependencies = { + fileExistsFn: fs.existsSync, + unlinkFileFn: fs.unlinkSync, + } +) { + const { fileExistsFn, unlinkFileFn } = dependencies; + const path = getLockFilePath(network, chain); + + if (!fileExistsFn(path)) return; + unlinkFileFn(path); +} diff --git a/relayer-cli/src/utils/relayerHelpers.ts b/relayer-cli/src/utils/relayerHelpers.ts index c1ab5340..28e48ebe 100644 --- a/relayer-cli/src/utils/relayerHelpers.ts +++ b/relayer-cli/src/utils/relayerHelpers.ts @@ -1,14 +1,9 @@ import * as fs from "fs"; +import { claimLock, releaseLock } from "./lock"; import ShutdownManager from "./shutdownManager"; async function initialize(chainId: number, network: string): Promise { - const lockFileName = "./state/" + network + "_" + chainId + ".pid"; - - if (fs.existsSync(lockFileName)) { - console.log("Skipping chain with process already running, delete pid file to force", chainId); - throw new Error("Already running"); - } - fs.writeFileSync(lockFileName, process.pid.toString(), { encoding: "utf8" }); + claimLock(network, chainId); // STATE_DIR is absolute path of the directory where the state files are stored // STATE_DIR must have trailing slash @@ -39,10 +34,7 @@ async function updateStateFile(chainId: number, createdTimestamp: number, nonceF }; fs.writeFileSync(chain_state_file, JSON.stringify(json), { encoding: "utf8" }); - const lockFileName = "./state/" + network + "_" + chainId + ".pid"; - if (fs.existsSync(lockFileName)) { - fs.unlinkSync(lockFileName); - } + releaseLock(network, chainId); } async function setupExitHandlers(chainId: number, shutdownManager: ShutdownManager, network: string) {