X-Git-Url: https://wannabe.guru.org/gitweb/?a=blobdiff_plain;f=src%2Fpyutils%2Ffiles%2Flockfile.py;h=937c8631b9462bf168eb44b03bba79902918e4c7;hb=c5bdf67d6424f11e86f8f61e915dd6636688acf3;hp=11bb1001156127eaa5ded750d6fadb74f77884fe;hpb=69566c003b4f1c3a4905f37d3735d7921502d14a;p=pyutils.git diff --git a/src/pyutils/files/lockfile.py b/src/pyutils/files/lockfile.py index 11bb100..937c863 100644 --- a/src/pyutils/files/lockfile.py +++ b/src/pyutils/files/lockfile.py @@ -2,7 +2,14 @@ # © Copyright 2021-2022, Scott Gasch -"""File-based locking helper.""" +"""This is a lockfile implementation I created for use with cronjobs +on my machine to prevent multiple copies of a job from running in +parallel. When one job is running this code keeps a file on disk to +indicate a lock is held. Other copies will fail to start if they +detect this lock until the lock is released. There are provisions in +the code for timing out locks, cleaning up a lock when a signal is +received, gracefully retrying lock acquisition on failure, etc... +""" from __future__ import annotations @@ -17,16 +24,16 @@ import warnings from dataclasses import dataclass from typing import Literal, Optional -from pyutils import config, decorator_utils -from pyutils.datetimez import datetime_utils +from pyutils import argparse_utils, config, decorator_utils +from pyutils.datetimes import datetime_utils -cfg = config.add_commandline_args(f'Lockfile ({__file__})', 'Args related to lockfiles') +cfg = config.add_commandline_args(f"Lockfile ({__file__})", "Args related to lockfiles") cfg.add_argument( - '--lockfile_held_duration_warning_threshold_sec', - type=float, - default=60.0, - metavar='SECONDS', - help='If a lock is held for longer than this threshold we log a warning', + "--lockfile_held_duration_warning_threshold", + type=argparse_utils.valid_duration, + default=datetime.timedelta(60.0), + metavar="DURATION", + help="If a lock is held for longer than this threshold we log a warning", ) logger = logging.getLogger(__name__) @@ -84,18 +91,18 @@ class LockFile(contextlib.AbstractContextManager): """ self.is_locked: bool = False self.lockfile: str = lockfile_path - self.locktime: Optional[int] = None + self.locktime: Optional[float] = None self.override_command: Optional[str] = override_command if do_signal_cleanup: signal.signal(signal.SIGINT, self._signal) signal.signal(signal.SIGTERM, self._signal) self.expiration_timestamp = expiration_timestamp - def locked(self): + def locked(self) -> bool: """Is it locked currently?""" return self.is_locked - def available(self): + def available(self) -> bool: """Is it available currently?""" return not os.path.exists(self.lockfile) @@ -115,12 +122,13 @@ class LockFile(contextlib.AbstractContextManager): contents = self._get_lockfile_contents() logger.debug(contents) f.write(contents) - logger.debug('Success; I own %s.', self.lockfile) + self.locktime = datetime.datetime.now().timestamp() + logger.debug("Success; I own %s.", self.lockfile) self.is_locked = True return True except OSError: pass - logger.warning('Couldn\'t acquire %s.', self.lockfile) + logger.warning("Couldn't acquire %s.", self.lockfile) return False def acquire_with_retries( @@ -157,7 +165,7 @@ class LockFile(contextlib.AbstractContextManager): self._detect_stale_lockfile() return _try_acquire_lock_with_retries() - def release(self): + def release(self) -> None: """Release the lock""" try: os.unlink(self.lockfile) @@ -167,9 +175,9 @@ class LockFile(contextlib.AbstractContextManager): def __enter__(self): if self.acquire_with_retries(): - self.locktime = datetime.datetime.now().timestamp() return self - msg = f"Couldn't acquire {self.lockfile}; giving up." + contents = self._get_lockfile_contents() + msg = f"Couldn't acquire {self.lockfile} after several attempts. It's held by pid={contents.pid} ({contents.commandline}). Giving up." logger.warning(msg) raise LockFileException(msg) @@ -179,11 +187,13 @@ class LockFile(contextlib.AbstractContextManager): duration = ts - self.locktime if ( duration - >= config.config['lockfile_held_duration_warning_threshold_sec'] + >= config.config[ + "lockfile_held_duration_warning_threshold" + ].total_seconds() ): # Note: describe duration briefly only does 1s granularity... str_duration = datetime_utils.describe_duration_briefly(int(duration)) - msg = f'Held {self.lockfile} for {str_duration}' + msg = f"Held {self.lockfile} for {str_duration}" logger.warning(msg) warnings.warn(msg, stacklevel=2) self.release() @@ -201,7 +211,7 @@ class LockFile(contextlib.AbstractContextManager): if self.override_command: cmd = self.override_command else: - cmd = ' '.join(sys.argv) + cmd = " ".join(sys.argv) contents = LockFileContents( pid=os.getpid(), commandline=cmd, @@ -211,7 +221,7 @@ class LockFile(contextlib.AbstractContextManager): def _detect_stale_lockfile(self) -> None: try: - with open(self.lockfile, 'r') as rf: + with open(self.lockfile, "r") as rf: lines = rf.readlines() if len(lines) == 1: line = lines[0] @@ -224,7 +234,7 @@ class LockFile(contextlib.AbstractContextManager): os.kill(contents.pid, 0) except OSError: logger.warning( - 'Lockfile %s\'s pid (%d) is stale; force acquiring...', + "Lockfile %s's pid (%d) is stale; force acquiring...", self.lockfile, contents.pid, ) @@ -235,7 +245,7 @@ class LockFile(contextlib.AbstractContextManager): now = datetime.datetime.now().timestamp() if now > contents.expiration_timestamp: logger.warning( - 'Lockfile %s\'s expiration time has passed; force acquiring', + "Lockfile %s's expiration time has passed; force acquiring", self.lockfile, ) self.release()