Source code for cli2.lock
"""
Prevent multiple processes from doing the same operation on a host, using good
old Linux fnctl locking.
fcntl's ``flock`` is a great choice to prevent deadlocks if/when your process
crashes because the system will release any locks of a process when it dies.
``man fcntl`` for details.
Anyway, we provide a Lock class that acts as a context manager for both
blocking and non-blocking fcntl locks.
It's especially useful to orchestrate Ansible Action plugins.
Blocking locks
--------------
Consider this little program:
.. code-block:: python
import cli2
import os
os.environ['LOG_LEVEL'] = 'DEBUG'
with cli2.Lock('/tmp/mylock') as lock:
input('say hello')
- Run it in a first terminal: it will log "Acquired" and show the "say hello"
input.
- Run it in another terminal: it will log "Waiting"
- Type enter in the first terminal: it will release the lock and exit
- Then the second terminal will log "Acquired" and display the say hello input
You got this: two programs cannot enter the same blocking lock at the same
time.
Non blocking locks
------------------
You're starting a bunch of processes that potentially want to do the same thing
at the same time, ie. download a file to cache locally prior to sending it.
Only one process must do the caching download, the first one that gets the
lock, all others will sit there and wait.
This is possible with a non-blocking lock that we later convert into a blocking
lock.
.. code-block:: python
with cli2.Lock('/tmp/mylock', blocking=False) as lock:
if lock.acquired:
# we got the lock, proceed to downloading safely
do_download()
else:
# couldn't acquired the lock because another process got it
# let's just wait for that other process to finish by converting
# the non-blocking lock into a blocking one
lock.block()
# all processes can safely process to uploading
do_upload()
"""
import fcntl
import functools
import os
from pathlib import Path
[docs]
class Lock:
"""
fcntl flock context manager, blocking and non blocking modes
In doubt? set :envvar:`LOG_LEVEL` to ``DEBUG`` and you will see exactly
what's happening.
.. py:attribute:: lock_path
Path to the file that we're going to use with flock.
.. py:attribute:: blocking
If True, the locker automatically blocks. Otherwise, you need to check
:py:attr:`acquired` and call :py:meth:`block` yourself.
.. py:attribute:: prefix
Arbitrary string that will be added to logs.
"""
def __init__(self, lock_path, blocking=True, prefix=None):
self.lock_path = Path(lock_path)
self.blocking = blocking
self.acquired = True
self.prefix = prefix or ''
@functools.cached_property
def log(self):
from .log import log
return log.bind(
blocking=self.blocking,
prefix=self.prefix,
lock_path=self.lock_path,
)
[docs]
def block(self):
""" Basically converts a non-blocking lock into a blocking one """
self.blocking = True
self.acquire()
def __enter__(self):
self.log.debug('Waiting')
self.lock_path.parent.mkdir(parents=True, exist_ok=True)
self.fp = self.lock_path.open('w+')
if self.blocking:
self.acquire()
return self
try:
fcntl.flock(
self.fp.fileno(),
fcntl.LOCK_EX | fcntl.LOCK_NB,
)
except BlockingIOError:
self.log.info('Not acquired but non blocking: proceeding')
self.acquired = False
else:
self.log.info('Acquired')
return self
def __exit__(self, _type, value, tb):
self.log.debug('Releasing')
self.release()
def acquire(self):
fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX)
self.log.info('Acquired')
def release(self):
if self.acquired or not self.blocking:
fcntl.flock(self.fp.fileno(), fcntl.LOCK_UN)
self.fp.close()
self.log.info('Released')
if self.acquired:
try:
os.unlink(self.lock_path)
except FileNotFoundError:
pass # already deleted, or locked by another process
else:
self.log.info('Deleted')