Source code for MultiLock
"""File-based advisory lock for safe concurrent NetCDF database access.
Wraps ``simpleflock`` with an additional per-process lock file layer so that
multiple worker processes can safely read and write the same NetCDF database
without data corruption.
"""
import glob
import logging
import os
import time
import numpy as np
import simpleflock
import config
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
[docs]
class MultiLock():
"""Context manager that provides exclusive access to a file path.
Layers a per-process ``.pid.lock`` sentinel file on top of
``simpleflock`` to close the race window where ``simpleflock`` can
briefly grant the system lock to two processes simultaneously. The
calling process:
1. Acquires the ``simpleflock`` system lock on ``<path>.lock``.
2. Creates its own ``<path>.<pid>.lock`` sentinel file.
3. Releases the system lock.
4. Loops until no *other* sentinel files exist, sleeping a random
sub-second interval on each iteration to break ties.
This guarantees that at most one process holds the advisory lock at any
time, even when the underlying ``simpleflock`` implementation has a
brief grant-to-two bug.
Parameters
----------
path : str or os.PathLike
Absolute path to the file being protected (typically a NetCDF
database such as ``modal_accel.nc``).
Examples
--------
::
with MultiLock('/path/to/database.nc'):
ds = xr.open_dataset('/path/to/database.nc')
# modify ds …
ds.to_netcdf('/path/to/database.nc')
"""
def __init__(self, path):
"""Store the path of the resource to protect.
Parameters
----------
path : str or os.PathLike
Absolute path to the file that will be locked.
"""
self._path = path
self._this_lockfile = None
def __enter__(self):
"""Acquire exclusive access to the protected file.
Blocks until no other process holds the lock. Uses a randomised
back-off sleep (uniform in [0, 1) seconds) to avoid live-lock when
two processes race for the same resource.
Returns
-------
None
This context manager yields nothing; callers use ``with
MultiLock(path):`` without ``as``.
"""
self._this_lockfile = f'{self._path}.{config.pid}.lock'
# simpleflock sometimes gives lock to two processes
with simpleflock.SimpleFlock(f"{self._path}.lock"):
while True:
lockfile_list = glob.glob(f'{self._path}.*.lock')
# print(lockfile_list)
if len(lockfile_list) > 0:
if len(
lockfile_list) == 1 and lockfile_list[0] == self._this_lockfile:
# this processes lockfile is the only one, we can
# continue to modify the ds safely
logger.debug(f'Acquired lock on {self._path}.lock')
return
elif self._this_lockfile in lockfile_list:
# another process has created a lockfile meanwhile ->
# start over
os.remove(self._this_lockfile)
time.sleep(np.random.random())
else:
# another process currently holds the lock for this
# file
logger.warning(
'Wating for lockfile to release: {}'.format(lockfile_list))
time.sleep(np.random.random())
else:
# if no other lockfile exists -> create one
# continue in while loop to check for race conditions with
# othe processes
_fd = open(self._this_lockfile, 'w+')
_fd.close()
def __exit__(self, *args):
"""Release the lock by removing this process's sentinel file.
Parameters
----------
*args : tuple
Exception info (``exc_type``, ``exc_val``, ``exc_tb``); ignored.
The lock is released unconditionally even when the body of the
``with`` block raises an exception.
"""
os.remove(self._this_lockfile)