From ce933c3a77cf12b7745e9caa75dec2cddc362ddb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ma=C5=9Blanka?= <piotr.maslanka@henrietta.com.pl> Date: Tue, 31 Dec 2019 01:22:34 +0100 Subject: [PATCH] Pidlock (#36) --- .gitignore | 2 +- CHANGELOG.md | 4 +- MANIFEST.in | 1 - README.md | 4 +- docs/posix.rst | 10 ++ requirements.txt | 1 + satella/__init__.py | 2 +- satella/configuration/__init__.py | 4 + satella/posix/__init__.py | 4 +- satella/posix/pidlock.py | 94 ++++++++----------- setup.py | 3 +- .../test_sources/test_envvars.py | 3 +- tests/test_posix/__init__.py | 27 +++++- 13 files changed, 93 insertions(+), 66 deletions(-) diff --git a/.gitignore b/.gitignore index 062e6445..3acd50de 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ ################# satella.sublime* - +hs_err_pid*.log *.pydevproject .project .metadata diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ac80439..16a700b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # v2.2.3 -* _TBA_ +* renamed `AcquirePIDLock` to `PIDFileLock` +* more unit tests for `PIDFileLock` + * it finally works # v2.2.2 diff --git a/MANIFEST.in b/MANIFEST.in index cfabbb68..373701c7 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,3 @@ include LICENSE include README.md -include requirements.txt include CHANGELOG.md diff --git a/README.md b/README.md index 91dc6379..f591be91 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,9 @@ _satella.posix_ in unavailable on non-POSIX systems, but the rest should work OK Full [documentation](http://satella.readthedocs.io/en/latest/?badge=latest) is available for the brave souls that do decide to use this library. -Satella is a Python 3.5+ library for writing server applications, especially those dealing with mundane but useful things. +Satella is a zero-requirements Python 3.5+ library for writing +server applications, especially those dealing with mundane but +useful things. It also runs on PyPy. See [LICENSE](LICENSE) for text of the license. diff --git a/docs/posix.rst b/docs/posix.rst index 7e9e789f..e8f0dd49 100644 --- a/docs/posix.rst +++ b/docs/posix.rst @@ -23,3 +23,13 @@ Return if running as root .. autofunction:: satella.posix.is_running_as_root + +PIDFileLock +-------------- + +This is meant to acquire a lock on a file. + +.. autoclass:: satella.posix.PIDFileLock + :members: + +.. autoclass:: satella.posix.LockIsHeld diff --git a/requirements.txt b/requirements.txt index e69de29b..a4d92cc0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1 @@ +psutil diff --git a/satella/__init__.py b/satella/__init__.py index 94657e46..9749a659 100644 --- a/satella/__init__.py +++ b/satella/__init__.py @@ -1,2 +1,2 @@ # coding=UTF-8 -__version__ = '2.2.3a1' +__version__ = '2.2.3' diff --git a/satella/configuration/__init__.py b/satella/configuration/__init__.py index e69de29b..5d2c95b5 100644 --- a/satella/configuration/__init__.py +++ b/satella/configuration/__init__.py @@ -0,0 +1,4 @@ +from . import schema +from . import sources + +__all__ = ['schema', 'sources'] diff --git a/satella/posix/__init__.py b/satella/posix/__init__.py index cf61df57..b38c9099 100644 --- a/satella/posix/__init__.py +++ b/satella/posix/__init__.py @@ -5,12 +5,12 @@ POSIX things import os from .daemon import daemonize -from .pidlock import AcquirePIDLock +from .pidlock import PIDFileLock, LockIsHeld from .signals import hang_until_sig __all__ = [ 'daemonize', - 'AcquirePIDLock', + 'PIDFileLock', 'LockIsHeld', 'hang_until_sig', 'is_running_as_root', 'suicide' diff --git a/satella/posix/pidlock.py b/satella/posix/pidlock.py index 30ded11a..39e1d0d3 100644 --- a/satella/posix/pidlock.py +++ b/satella/posix/pidlock.py @@ -1,109 +1,95 @@ import logging import os -logger = logging.getLogger(__name__) - +import psutil -class FailedToAcquire(Exception): - """Failed to acquire the process lock file""" +logger = logging.getLogger(__name__) -class LockIsHeld(FailedToAcquire): +class LockIsHeld(Exception): """ Lock is held by someone - Has two attributes: - pid - integer - PID of the holder - is_alive - bool - whether the holder is an alive process + pid -- PID of the holder, who is alive """ + def __init__(self, pid): + self.pid = pid + -class AcquirePIDLock: +class PIDFileLock: """ Acquire a PID lock file. + Usable also on Windows + Usage: - >>> with AcquirePIDLock('myservice.pid'): + >>> with PIDFileLock('myservice.pid'): >>> ... rest of code .. Or alternatively - >>> pid_lock = AcquirePIDLock('myservice.pid') + >>> pid_lock = PIDFileLock('myservice.pid') >>> pid_lock.acquire() >>> ... >>> pid_lock.release() The constructor doesn't throw, __enter__ or acquire() does, one of: - * AcquirePIDLock.FailedToAcquire - base class for errors. Thrown if can't read the file - * AcquirePIDLock.LockIsHeld - lock is already held. This has two attributes - pid (int), the PID of holder, + * LockIsHeld - lock is already held. This has two attributes - pid (int), the PID of holder, and is_alive (bool) - whether the holder is an alive process """ - def __init__(self, pid, is_alive): - self.pid = pid - self.is_alive = is_alive - - def __init__(self, pid_file, base_dir=u'/var/run', delete_on_dead=False): + def __init__(self, pid_file, base_dir=u'/var/run'): """ Initialize a PID lock file object :param pid_file: rest of path :param base_dir: base lock directory - :param delete_on_dead: delete the lock file if holder is dead, and retry """ - self.delete_on_dead = delete_on_dead - self.path = os.path.join(base_dir, pid_file) - - self.fileno = None + self.file_no = None def release(self): - if self.fileno is not None: - os.close(self.fileno) + """ + Free the lock + """ + if self.file_no is not None: os.unlink(self.path) + self.file_no = None def acquire(self): """ Acquire the PID lock :raises LockIsHeld: if lock if held - :raises FailedToAcquire: if for example a directory exists in that place """ try: - self.fileno = os.open(self.path, os.O_CREAT | os.O_EXCL) - except (IOError, OSError): - try: - with open(self.path, 'rb') as flock: - try: - pid = int(flock.read()) - except ValueError: - logger.warning( - 'PID file found but doesn''t have an int, skipping') - return - except IOError as e: - raise FailedToAcquire(repr(e)) - - # Is this process alive? - try: - os.kill(pid, 0) - except OSError: # dead - raise LockIsHeld(pid, False) - else: - raise LockIsHeld(pid, True) + self.file_no = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + except (OSError, FileExistsError): + with open(self.path, 'r') as fin: + data = fin.read().strip() - def __enter__(self): - try: - self.acquire() - except LockIsHeld as e: - if self.delete_on_dead and (not e.is_alive): + try: + pid = int(data) + except ValueError: os.unlink(self.path) - self.acquire() + return self.acquire() + + if pid in {x.pid for x in psutil.process_iter()}: + raise LockIsHeld(pid) else: - raise + # does not exist + os.unlink(self.path) + return self.acquire() - self.success = True + fd = os.fdopen(self.file_no, 'w') + fd.write(str(os.getpid()) + '\n') + fd.close() + + def __enter__(self): + self.acquire() def __exit__(self, exc_type, exc_val, exc_tb): self.release() diff --git a/setup.py b/setup.py index 594a2324..620a3d4b 100644 --- a/setup.py +++ b/setup.py @@ -7,10 +7,11 @@ setup(keywords=['ha', 'high availability', 'scalable', 'scalability', 'server'], packages=find_packages(include=['satella', 'satella.*']), version=__version__, install_requires=[ + 'psutil' ], tests_require=[ "nose", "mock", "coverage" ], test_suite='nose.collector', - python_requires='!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*' + python_requires='!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*', ) diff --git a/tests/test_configuration/test_sources/test_envvars.py b/tests/test_configuration/test_sources/test_envvars.py index 66a22444..37a40f06 100644 --- a/tests/test_configuration/test_sources/test_envvars.py +++ b/tests/test_configuration/test_sources/test_envvars.py @@ -1,3 +1,4 @@ +from satella.configuration import sources from satella.configuration.sources import EnvVarsSource, OptionalSource, AlternativeSource, EnvironmentSource, \ StaticSource, MergingSource from .utils import SourceTestCase, mock_env @@ -6,7 +7,7 @@ from .utils import SourceTestCase, mock_env class TestEnvVarsSource(SourceTestCase): @mock_env('satella', '{"a":2}') def test_ok(self): - self.assertSourceHas(EnvVarsSource('satella'), {u"a": 2}) + self.assertSourceHas(sources.EnvVarsSource('satella'), {u"a": 2}) def test_none(self): self.assertSourceEmpty(OptionalSource(EnvVarsSource('satella'))) diff --git a/tests/test_posix/__init__.py b/tests/test_posix/__init__.py index 6864cbaa..b7f2695c 100644 --- a/tests/test_posix/__init__.py +++ b/tests/test_posix/__init__.py @@ -1,18 +1,26 @@ # coding=UTF-8 from __future__ import print_function, absolute_import, division +import multiprocessing import os import sys import unittest from mock import patch, Mock +from satella.posix import PIDFileLock, LockIsHeld + + +def acquire_lock_file_and_wait_for_signal(q, p): + with PIDFileLock('lock', '.'): + p.put(None) + q.get() + class TestPidlock(unittest.TestCase): - def test_pidlock(self): - from satella.posix.pidlock import AcquirePIDLock - with AcquirePIDLock('lock', '.', delete_on_dead=True): + def test_pidlock(self): + with PIDFileLock('lock', '.'): self.assertTrue(os.path.exists('./lock')) r = open('./lock', 'rb').read() try: @@ -23,6 +31,19 @@ class TestPidlock(unittest.TestCase): self.assertTrue(not os.path.exists('./lock')) + def test_pidlock_multiacquire(self): + q, p = multiprocessing.Queue(), multiprocessing.Queue() + process = multiprocessing.Process(target=acquire_lock_file_and_wait_for_signal, args=(q, p)) + process.start() + p.get() + n = PIDFileLock('lock', '.') + try: + self.assertRaises(LockIsHeld, lambda: n.acquire()) + finally: + q.put(None) + process.terminate() + process.join() + class TestDaemon(unittest.TestCase): @unittest.skipIf('win' in sys.platform, 'Running on Windows') -- GitLab