from __future__ import annotations
import copy
import json
import logging
import os
import re
import subprocess
import tempfile
import time
import warnings
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from shutil import which
from typing import TYPE_CHECKING, Any
import bson
from maggma.stores.mongolike import MongoStore, MongoURIStore
from monty.io import zopen
from pymongo import ReturnDocument
from pymongo.errors import PyMongoError
from jobflow_remote.utils.data import deep_merge_dict, suuid
if TYPE_CHECKING:
from collections.abc import Iterable, Mapping
from pymongo.collection import Collection
logger = logging.getLogger(__name__)
[docs]
class MongoLock:
"""
Context manager to lock a document in a MongoDB database.
Main characteristics and functionalities:
* Lock is acquired by setting a ``lock_id`` and ``lock_time`` (UTC
timestamp) value in the locked document.
* Filter the document to select based on a query and sorting.
It uses ``find_one_and_update``, thus resulting in a single document locked.
* Can wait for a lock to be released (set ``sleep`` to enable polling).
* Can forcibly break an existing lock (``break_lock=True``).
* Can return the locked document even if the lock could not be acquired
(useful for determining if a document is locked or no document matches
the query).
* Accepts all the arguments that can be passed to ``find_one_and_update``.
* A custom update can be performed on the document when acquiring the lock
via the ``update`` parameter.
* Allows to pass properties that will be set in the document at the moment
of releasing the lock via ``update_on_release``.
* Lock id value can be customized. If not provided, a randomly generated
uuid is used.
Attributes
----------
locked_document : dict | None
The document that was locked. ``None`` if the lock could not be acquired.
unavailable_document : dict | None
The document that matched the filter but was already locked. Only
populated when ``get_locked_doc=True`` and the lock could not be acquired.
lock_id : str
The identifier used for this lock instance. Stored in the document
under the ``lock_id`` key together with a ``lock_time`` UTC timestamp
recording when the lock was acquired. Both fields are cleared on
release.
update_on_release : dict | list
MongoDB update to apply to the document when the lock is released.
Cannot be set together with ``delete_on_release``.
delete_on_release : bool
If ``True``, the document will be deleted when the lock is released.
Cannot be set together with ``update_on_release``.
Notes
-----
If an exception is raised inside the ``with`` block, both
``update_on_release`` and ``delete_on_release`` are ignored: the lock is
released but the document is left unchanged and not deleted. A warning is
logged in either case.
Setting both ``update_on_release`` and ``delete_on_release`` raises a
``ValueError``.
If the lock cannot be released (e.g. the document was modified externally),
a warning is emitted. If ``delete_on_release`` is set and the deletion
fails, a ``RuntimeError`` is raised.
Examples
--------
Trying to acquire the lock on a document based on the state:
>>> with MongoLock(collection, {"state": "READY"}) as lock:
... print(lock.locked_document["state"])
READY
If lock cannot be acquired (no document matching filter or that
document is locked) the ``locked_document`` is ``None``:
>>> with MongoLock(collection, {"state": "READY"}) as lock:
... print(lock.locked_document)
None
Wait for up to 60 seconds in case the required document is already locked,
checking every 10 seconds. Note that ``sleep`` must be set for the retry
loop to activate (it defaults to ``None``, meaning no waiting):
>>> with MongoLock(
... collection,
... {"uuid": "5b84228b-d019-47fe-b0a0-564b36aa85ed"},
... sleep=10,
... max_wait=60,
... ) as lock:
... print(lock.locked_document)
None
In case the lock cannot be acquired, expose the already-locked document
via ``unavailable_document``:
>>> with MongoLock(
... collection,
... {"uuid": "5b84228b-d019-47fe-b0a0-564b36aa85ed"},
... get_locked_doc=True,
... ) as lock:
... if lock.locked_document is None and lock.unavailable_document:
... print(lock.unavailable_document["lock_id"])
8d68404f-c77a-461b-859c-40bb0af1979f
Forcibly break an existing lock on a document:
>>> with MongoLock(
... collection,
... {"uuid": "5b84228b-d019-47fe-b0a0-564b36aa85ed"},
... break_lock=True,
... ) as lock:
... print(lock.locked_document is not None)
True
Atomically update the document when acquiring the lock:
>>> with MongoLock(
... collection,
... {"state": "READY"},
... update={"$set": {"state": "CHECKED_OUT"}},
... ) as lock:
... if lock.locked_document:
... print(lock.locked_document["state"])
CHECKED_OUT
Set values in the document upon lock release:
>>> with MongoLock(collection, {"state": "READY"}) as lock:
... if lock.locked_document:
... # Perform some operations based on the job...
... lock.update_on_release = {"$set": {"state": "CHECKED_OUT"}}
Delete the locked document upon lock release:
>>> with MongoLock(collection, {"state": "READY"}) as lock:
... if lock.locked_document:
... # decide if the document should be deleted
... lock.delete_on_release = True
"""
LOCK_KEY = "lock_id"
LOCK_TIME_KEY = "lock_time"
def __init__(
self,
collection: Collection,
filter: Mapping[str, Any], # noqa: A002
update: Mapping[str, Any] | None = None,
break_lock: bool = False,
lock_id: str | None = None,
sleep: int | None = None,
max_wait: int = 600,
projection: Mapping[str, Any] | Iterable[str] | None = None,
get_locked_doc: bool = False,
**kwargs,
) -> None:
"""
Parameters
----------
collection
The MongoDB collection containing the document to lock.
filter
A MongoDB query to select the document.
update
A dictionary that will be passed to find_one_and_update to update the
locked document at the moment of acquiring the lock.
break_lock
True if the context manager is allowed to forcibly break a lock.
lock_id
The is used for the lock in the document. If None a randomly generated
uuid will be used.
sleep
The amount of second to sleep between consecutive checks while waiting
for a lock to be released.
max_wait
The amount of seconds to wait for a lock to be released.
projection
The projection passed to the find_one_and_update that locks the document.
get_locked_doc
If True, if the lock cannot be acquired because the document matching
the filter is already locked, the locked document will be fetched and
set in the unavailable_document attribute.
kwargs
All the other args are passed to find_one_and_update.
"""
self.collection = collection
self.filter = filter or {}
self.update = update
self.break_lock = break_lock
self.locked_document: dict | None = None
self.unavailable_document: dict | None = None
self.lock_id = lock_id or suuid()
self.kwargs = kwargs
self._update_on_release: dict | list = {}
self._delete_on_release: bool = False
self.sleep = sleep
self.max_wait = max_wait
self.projection = projection
self.get_locked_doc = get_locked_doc
@property
def update_on_release(self) -> dict | list:
"""
The update_on_release value.
Returns:
dict | list: The value of update_on_release.
"""
return self._update_on_release
@update_on_release.setter
def update_on_release(self, value: dict | list):
"""
Set the value of update_on_release.
If set its value will be used to update the document with additional
properties upon lock release.
For example:
lock.update_on_release = {"$set": {"state": "CHECKED_OUT"}}
Cannot be set together with delete_on_release.
Parameters
----------
value : dict | list
The value to set for update_on_release.
"""
if self.delete_on_release:
raise ValueError(
"delete_on_release and update_on_release cannot be set simultaneously"
)
self._update_on_release = value
@property
def delete_on_release(self) -> bool:
"""
The delete_on_release property.
Returns:
bool: Whether the document will be deleted upon lock release.
"""
return self._delete_on_release
@delete_on_release.setter
def delete_on_release(self, value: bool):
"""
Set the value of delete_on_release.
If True the document will be deleted upon lock release.
For example:
lock.delete_on_release = True
Cannot be set together with update_on_release.
Parameters
----------
value : bool
The value to set for delete_on_release.
"""
if self.update_on_release:
raise ValueError(
"delete_on_release and update_on_release cannot be set simultaneously"
)
self._delete_on_release = value
@property
def is_locked(self) -> bool:
"""Return whether the document was locked before trying to acquire the lock.
Notes
-----
This method should be used only inside the "with" context.
"""
return self.locked_document is None
[docs]
@classmethod
def get_lock_time(cls, d: dict):
"""Get the time the document was locked on a dictionary."""
return d.get(cls.LOCK_TIME_KEY)
[docs]
@classmethod
def get_lock_id(cls, d: dict):
"""Get the lock id on a dictionary."""
return d.get(cls.LOCK_KEY)
[docs]
def acquire(self) -> None:
"""Acquire the lock."""
# Set the lock expiration time
now = datetime.now(timezone.utc)
db_filter = copy.deepcopy(dict(self.filter))
projection = self.projection
# if projecting always get the lock as well
if projection:
projection = list(projection)
projection.extend([self.LOCK_KEY, self.LOCK_TIME_KEY])
# Modify the filter if the document should not be fetched if
# the lock cannot be acquired. Otherwise, keep the original filter.
if not self.break_lock and not self.sleep and not self.get_locked_doc:
db_filter.update({self.LOCK_KEY: None})
# Prepare the update to be performed when acquiring the lock.
# A combination of the input update and the setting of the lock.
lock_set = {self.LOCK_KEY: self.lock_id, self.LOCK_TIME_KEY: now}
update: dict[str, dict] = defaultdict(dict)
if self.update:
update.update(copy.deepcopy(self.update))
update["$set"].update(lock_set)
# If the document should be fetched even if the lock could not be acquired
# the updates should be made conditional.
# Note: sleep needs to fetch the document, otherwise it is impossible to
# determine if the filter did not return any document or if the document
# was locked.
if (self.sleep or self.get_locked_doc) and not self.break_lock:
for operation, dict_vals in update.items():
for k, v in dict_vals.items():
cond = {
"$cond": {
"if": {"$gt": [f"${self.LOCK_KEY}", None]},
"then": f"${k}",
"else": v,
}
}
update[operation][k] = cond
update = [dict(update)] # type: ignore[assignment]
# Try to acquire the lock by updating the document with a unique identifier
# and the lock expiration time
logger.debug(f"try acquiring lock with filter: {db_filter}")
t0 = time.time()
while True:
result = self.collection.find_one_and_update(
db_filter,
update,
upsert=False,
return_document=ReturnDocument.AFTER,
projection=projection,
**self.kwargs,
)
if result:
lock_acquired = self.get_lock_id(result) == self.lock_id
if lock_acquired:
self.locked_document = result
break
# if the lock could not be acquired optionally sleep or
# exit if waited for enough time.
if self.sleep and (time.time() - t0) < self.max_wait:
logger.debug("sleeping")
time.sleep(self.sleep)
else:
self.unavailable_document = result
break
else:
# If no document the conditions could not be met.
# Either the requested filter does not find match a document
# or those fitting are locked.
break
[docs]
def release(self, exc_type, exc_val, exc_tb) -> None:
"""Release the lock."""
# TODO if failed to release the lock maybe retry before failing
if self.locked_document is None:
return
# Release the lock by removing the unique identifier and lock expiration time
base_update: dict = {"$set": {self.LOCK_KEY: None, self.LOCK_TIME_KEY: None}}
update: list | dict = base_update
# TODO maybe set on release only if no exception was raised?
if self.update_on_release:
# if an exception raised inside the context manager do not update the document
if exc_type is not None:
logger.warning(
f"A {type(exc_type)} exception was raised while the document was locked. "
f"The update_on_release {self.update_on_release} will not be applied."
)
elif isinstance(self.update_on_release, list):
update = [base_update, *self.update_on_release]
else:
update = deep_merge_dict(base_update, self.update_on_release)
logger.debug(f"release lock with update: {update}")
# if an exception raised inside the context manager do not delete the document
if self.delete_on_release and exc_type is None:
result = self.collection.delete_one(
{"_id": self.locked_document["_id"], self.LOCK_KEY: self.lock_id}
)
if result.deleted_count == 0:
raise RuntimeError("Could not delete the locked document upon release")
else:
if self.delete_on_release and exc_type is not None:
logger.warning(
f"A {type(exc_type)} exception was raised while the document was locked. "
f"The document will not be deleted, as instead requested by delete_on_release."
)
result = self.collection.update_one(
{"_id": self.locked_document["_id"], self.LOCK_KEY: self.lock_id},
update,
upsert=False,
)
# Check if the lock was successfully released
if result.modified_count == 0:
msg = (
f"Could not release lock for document {self.locked_document['_id']}"
)
warnings.warn(msg, stacklevel=2)
self.locked_document = None
def __enter__(self):
self.acquire()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if self.locked_document:
self.release(exc_type, exc_val, exc_tb)
[docs]
class LockedDocumentError(Exception):
"""Exception to signal a problem when locking the document."""
[docs]
class RunnerLockedError(LockedDocumentError):
"""Exception to signal a problem when locking a Runner document."""
[docs]
@classmethod
def from_runner_doc(cls, doc: dict, additional_msg: str | None = None):
lock_id = doc[MongoLock.LOCK_KEY]
lock_date = doc[MongoLock.LOCK_TIME_KEY]
date_str = lock_date.isoformat(timespec="seconds") if lock_date else None
msg = f"Runner document is locked with lock_id {lock_id} since {date_str} UTC."
if additional_msg:
msg += " " + additional_msg
return cls(msg)
[docs]
class JobLockedError(LockedDocumentError):
"""Exception to signal a problem when locking a Job document."""
[docs]
@classmethod
def from_job_doc(cls, doc: dict, additional_msg: str | None = None):
lock_id = doc[MongoLock.LOCK_KEY]
lock_date = doc[MongoLock.LOCK_TIME_KEY]
date_str = lock_date.isoformat(timespec="seconds") if lock_date else None
msg = f"Job with db_id {doc['db_id']} is locked with lock_id {lock_id} since {date_str} UTC."
if additional_msg:
msg += " " + additional_msg
return cls(msg)
[docs]
class FlowLockedError(LockedDocumentError):
"""Exception to signal a problem when locking a Flow document."""
[docs]
@classmethod
def from_flow_doc(cls, doc: dict, additional_msg: str | None = None):
lock_id = doc[MongoLock.LOCK_KEY]
lock_date = doc[MongoLock.LOCK_TIME_KEY]
date_str = lock_date.isoformat(timespec="seconds") if lock_date else None
msg = f"Flow with uuid {doc['uuid']} is locked with lock_id {lock_id} since {date_str} UTC."
if additional_msg:
msg += " " + additional_msg
return cls(msg)
[docs]
class MissingDocumentError(Exception):
"""Exception to signal that a document is missing from the DB"""
[docs]
def mongo_operation(
store: MongoStore | MongoURIStore,
file_path: str | Path,
operation: str,
collection: str | None,
mongo_bin_path: str | None = None,
compress: bool = False,
) -> tuple[str, str]:
"""
Execute a mongo operation (mongodump or mongorestore) on a given store.
Parameters
----------
store
The store containing the data to be backed up.
file_path
The path of the folder where the backup files are located.
operation
The mongo operation to perform. Can be either 'mongodump' or 'mongorestore'.
collection
The name of the collection to be backed up. If None the collection defined in
the store will be used.
mongo_bin_path
The path to the folder containing the mongo executable. If None, the executable is
searched in the PATH.
compress
If True, the backup files are compressed.
Returns
-------
tuple[str, str]
The stdout and stderr of the executed command.
Notes
-----
If the store defines credentials (username/password or a URI), these are
passed to the executable through a temporary configuration file, to avoid
exposing them in the process list. This requires the mongodb database
tools version 100.3 or higher. With older versions use the pure python
implementation instead.
"""
if operation not in ("mongodump", "mongorestore"):
raise ValueError(f"Operation {operation} not supported")
if mongo_bin_path:
operation = os.path.join(mongo_bin_path, operation)
if not which(operation):
raise RuntimeError(
f"It looks like the command {operation} is not available. Check the path or consider "
f"installing the mongodb database tools. Alternatively use the pure python implementation."
)
# Sensitive values (the password or the URI, that may embed credentials)
# are not added to the command line, where they would be visible in the
# process list, but passed through a temporary configuration file.
secrets: dict[str, str] = {}
# here is not checked with isinstance(), because the current other subclasses will fail
if type(store) is MongoURIStore:
cmd = [operation]
secrets["uri"] = store.uri
elif type(store) is MongoStore:
cmd = [
operation,
"--host",
store.host,
"--port",
str(store.port),
]
if store.username and store.password:
cmd.extend(["--username", store.username])
secrets["password"] = store.password
if store.auth_source:
cmd.extend(["--authenticationDatabase", store.auth_source])
elif store.username or store.password:
raise ValueError(
"To use the mongotools the username and password in the queue store should be "
"either both present or both absent."
)
else:
raise ValueError(
f"Unsupported store type {type(store).__name__}. Consider using the python version."
)
# check this afterwards, since other stores may not have ssh_tunnel attribute
if store.ssh_tunnel is not None:
raise NotImplementedError(
"SSH tunnel is not supported. Consider using the python version."
)
cmd.extend(["--db", store.database, "--collection", collection])
if compress:
cmd.append("--gzip")
if operation.endswith("mongodump"):
cmd.extend(["--out", str(file_path)])
elif operation.endswith("mongorestore"):
cmd.append(str(file_path))
config_path = None
try:
if secrets:
with tempfile.NamedTemporaryFile(
mode="w", suffix=".yaml", delete=False
) as config_file:
# JSON escaping of the values is also valid YAML for double-quoted
# scalars, so arbitrary passwords are handled without a YAML dumper
for key, value in secrets.items():
config_file.write(f"{key}: {json.dumps(value)}\n")
config_path = config_file.name
cmd.extend(["--config", config_path])
# Do not use shell=True: with the list form the arguments are not
# processed by a shell, so they cannot be altered or interpreted as
# additional commands.
result = subprocess.run(cmd, check=True, capture_output=True, text=True) # noqa: S603
finally:
if config_path:
os.unlink(config_path)
str_cmd = " ".join(cmd)
logger.debug(
f"output during execution of '{str_cmd}'. Stdout: {result.stdout}. Stderr: {result.stderr}"
)
return result.stdout, result.stderr
[docs]
def mongodump_from_store(
store: MongoStore | MongoURIStore,
output_path: str | Path,
collection: str | None = None,
mongo_bin_path: str | None = None,
compress: bool = False,
) -> int:
"""
Use mongodump to dump a collection from a MongoDB store to a file.
Parameters
----------
store
The store containing the data to be backed up.
output_path
The path of the folder where the backup files are located.
collection
The name of the collection to be backed up. If None the collection defined in
the store will be used.
mongo_bin_path
The path to the folder containing the mongo executable. If None, the executable is
searched in the PATH.
compress
If True, the backup files are compressed.
Returns
-------
int
The number of documents dumped.
"""
stdout, stderr = mongo_operation(
store=store,
collection=collection,
file_path=output_path,
operation="mongodump",
mongo_bin_path=mongo_bin_path,
compress=compress,
)
# Extract the number of documents dumped
match = re.search(r"done dumping .*\((\d+) documents*\)", stderr)
return int(match.group(1)) if match else 0
[docs]
def mongorestore_to_store(
store: MongoStore | MongoURIStore,
input_file: str | Path,
collection: str | None = None,
mongo_bin_path: str | None = None,
compress: bool | None = None,
) -> None:
"""
Restore a collection from a BSON file using mongorestore.
Parameters
----------
store
The store where the data should be restored.
input_file
The path of the BSON file containing the data to be restored.
collection
The name of the collection to be backed up. If None the collection defined in
the store will be used.
mongo_bin_path
The path to the folder containing the mongo executable. If None, the executable is
searched in the PATH.
compress
If True, the input file is expected to be compressed. If None it will be determined
based on the file extension.
"""
if compress is None:
compress = Path(input_file).name.endswith(".gz")
mongo_operation(
store=store,
collection=collection,
file_path=input_file,
operation="mongorestore",
mongo_bin_path=mongo_bin_path,
compress=compress,
)
[docs]
def pymongo_dump(
collection: Collection, output_path: str | Path, compress: bool = False
) -> int:
"""
Dump the contents of a PyMongo collection to a BSON file.
Parameters
----------
collection
The PyMongo collection to be dumped.
output_path
The path of the folder where the BSON file should be saved.
compress : bool
If True, the output file is compressed.
Returns
-------
int
The number of documents dumped.
"""
dir_path = Path(output_path) / collection.database.name
dir_path.mkdir(exist_ok=True, parents=True)
# add the db name for consistency with mongodump and use the collection name as file name
file_name = f"{collection.name}.bson"
if compress:
file_name += ".gz"
with zopen(dir_path / file_name, "wb") as f:
num_documents = 0
for doc in collection.find():
f.write(bson.BSON.encode(doc))
num_documents += 1
return num_documents
[docs]
def pymongo_restore(collection: Collection, input_file: str | Path) -> None:
"""
Restore the contents of a BSON file to a PyMongo collection.
Parameters
----------
collection
The PyMongo collection to be dumped.
input_file
The path of the BSON file used to restore the data.
"""
try:
with zopen(input_file, "rb") as f:
# This conditional is needed because insert_many cannot be used with an empty list
if docs := bson.decode_all(f.read()):
collection.insert_many(docs)
except PyMongoError as e:
raise RuntimeError(f"Error during PyMongo restore: {e!s}") from e
except OSError as e:
raise RuntimeError(f"Error reading from file: {e!s}") from e