From e5bc323a1807b0fe5c545a3e1553bbc957cdf816 Mon Sep 17 00:00:00 2001 From: Neil Massey Date: Wed, 25 Jan 2023 14:42:47 +0000 Subject: [PATCH 01/41] Renamed test_run --- test_run/test_run_start.sh | 9 --------- test_run/test_run_stop.sh | 3 --- 2 files changed, 12 deletions(-) delete mode 100755 test_run/test_run_start.sh delete mode 100755 test_run/test_run_stop.sh diff --git a/test_run/test_run_start.sh b/test_run/test_run_start.sh deleted file mode 100755 index ac770269..00000000 --- a/test_run/test_run_start.sh +++ /dev/null @@ -1,9 +0,0 @@ -#! /usr/bin/env bash -if [[ ! -d ~/nlds_log ]] -then - mkdir ~/nlds_log -fi - -source $HOME/python-venvs/nlds-venv/bin/activate -# start a named screen session -screen -S nlds -c test_run.rc diff --git a/test_run/test_run_stop.sh b/test_run/test_run_stop.sh deleted file mode 100755 index 0c7f11c8..00000000 --- a/test_run/test_run_stop.sh +++ /dev/null @@ -1,3 +0,0 @@ -#! /usr/bin/env bash -screen -d nlds -screen -r nlds -X quit From 8c49229d3a47705d2b1b26512a91978916a90cb8 Mon Sep 17 00:00:00 2001 From: Jack Leland Date: Thu, 2 Feb 2023 12:16:48 +0100 Subject: [PATCH 02/41] Refactor retries into separate class --- nlds/details.py | 36 ++++++++++++++------- nlds/rabbit/consumer.py | 2 +- nlds_processors/catalog/catalog_worker.py | 16 ++++----- nlds_processors/index.py | 12 ++----- nlds_processors/transferers/get_transfer.py | 16 +++------ nlds_processors/transferers/put_transfer.py | 6 ++-- 6 files changed, 43 insertions(+), 45 deletions(-) diff --git a/nlds/details.py b/nlds/details.py index 9a635297..d17dccee 100644 --- a/nlds/details.py +++ b/nlds/details.py @@ -35,6 +35,21 @@ def __str__(self): "NOT_RECOGNISED", "UNINDEXED"][self.value] + +class Retries(BaseModel): + count: Optional[int] = 0 + reasons: Optional[List[str]] = [] + + def increment(self, reason: str = None) -> None: + self.count += 1 + if reason: + self.reasons.append(reason) + + def reset(self) -> None: + self.count = 0 + self.reasons = [] + + class PathDetails(BaseModel): original_path: Optional[str] object_name: Optional[str] @@ -47,8 +62,7 @@ class PathDetails(BaseModel): modify_time: Optional[float] path_type: Optional[PathType] = PathType.UNINDEXED link_path: Optional[str] - retries: Optional[int] = 0 - retry_reasons: Optional[List[str]] = [] + retries: Retries @property def path(self) -> str: @@ -69,8 +83,8 @@ def to_json(self): "path_type": self.path_type.value, "link_path": self.link_path, }, - "retries": self.retries, - "retry_reasons": self.retry_reasons + "retries": self.retries.count, + "retry_reasons": self.retries.reasons } @classmethod @@ -137,12 +151,12 @@ def check_permissions(self, uid: int, gid: int, access=os.R_OK): path=self.original_path, stat_result=self.get_stat_result()) - def increment_retry(self, retry_reason: str = None) -> None: - self.retries += 1 - if retry_reason: - self.retry_reasons.append(retry_reason) + # def increment_retry(self, retry_reason: str = None) -> None: + # # self.retries += 1 + # # if retry_reason: + # # self.retry_reasons.append(retry_reason) + # raise DeprecationWarning - def reset_retries(self) -> None: - self.retries = 0 - self.retry_reasons = [] + # def reset_retries(self) -> None: + # raise DeprecationWarning diff --git a/nlds/rabbit/consumer.py b/nlds/rabbit/consumer.py index 403ea211..c7b360e2 100644 --- a/nlds/rabbit/consumer.py +++ b/nlds/rabbit/consumer.py @@ -266,7 +266,7 @@ def send_pathlist(self, pathlist: List[PathDetails], routing_key: str, if mode == FilelistType.processed: # Reset the retries upon successful indexing. for path_details in pathlist: - path_details.reset_retries() + path_details.retries.reset() elif mode == FilelistType.retry: # Delay the retry message depending on how many retries have been # accumulated. All retries in a retry list _should_ be the same so diff --git a/nlds_processors/catalog/catalog_worker.py b/nlds_processors/catalog/catalog_worker.py index 899f2327..104c9402 100644 --- a/nlds_processors/catalog/catalog_worker.py +++ b/nlds_processors/catalog/catalog_worker.py @@ -233,8 +233,8 @@ def _catalog_put(self, body: dict, rk_origin: str) -> None: if pd.retries > self.max_retries: self.failedlist.append(pd) else: - pd.increment_retry( - retry_reason=f"{e.message}" + pd.retries.increment( + reason=f"{e.message}" ) self.retrylist.append(pd) self.log(e.message, RMQC.RK_LOG_ERROR) @@ -377,8 +377,8 @@ def _catalog_get(self, body: dict, rk_origin: str) -> None: self.failedlist.append(file_details) else: self.retrylist.append(file_details) - file_details.increment_retry( - retry_reason=f"{e.message}" + file_details.retries.increment( + reason=f"{e.message}" ) self.log(e.message, RMQC.RK_LOG_ERROR) continue @@ -388,8 +388,8 @@ def _catalog_get(self, body: dict, rk_origin: str) -> None: if file_details.retries > self.max_retries: self.failedlist.append(file_details) else: - file_details.increment_retry( - retry_reason=f"{e.message}" + file_details.retries.increment( + reason=f"{e.message}" ) self.retrylist.append(file_details) self.log(e.message, RMQC.RK_LOG_ERROR) @@ -507,8 +507,8 @@ def _catalog_del(self, body: dict, rk_origin: str) -> None: if file_details.retries > self.max_retries: self.failedlist.append(file_details) else: - file_details.increment_retry( - retry_reason=f"{e.message}" + file_details.retries.increment( + reason=f"{e.message}" ) self.retrylist.append(file_details) self.log(e.message, RMQC.RK_LOG_ERROR) diff --git a/nlds_processors/index.py b/nlds_processors/index.py index be01416e..aff3768f 100644 --- a/nlds_processors/index.py +++ b/nlds_processors/index.py @@ -194,9 +194,7 @@ def index(self, raw_filelist: List[NamedTuple], rk_origin: str, # Increment retry counter and add to retry list reason = (f"Path:{path_details.path} is inaccessible.") self.log(reason, self.RK_LOG_DEBUG) - path_details.increment_retry( - retry_reason=reason - ) + path_details.retries.increment(retry_reason=reason) self.append_and_send( path_details, rk_retry, body_json, list_type="retry" ) @@ -253,9 +251,7 @@ def index(self, raw_filelist: List[NamedTuple], rk_origin: str, f"Path:{walk_path_details.path} is inaccessible." ) self.log(reason, self.RK_LOG_DEBUG) - walk_path_details.increment_retry( - retry_reason=reason - ) + walk_path_details.retries.increment(reason=reason) self.append_and_send( walk_path_details, rk_retry, body_json, list_type="retry" @@ -277,9 +273,7 @@ def index(self, raw_filelist: List[NamedTuple], rk_origin: str, else: reason = f"Path:{path_details.path} is of unknown type." self.log(reason, self.RK_LOG_DEBUG) - path_details.increment_retry( - retry_reason=reason - ) + path_details.retries.increment(reason=reason) self.append_and_send( path_details, rk_retry, body_json, list_type="retry" ) diff --git a/nlds_processors/transferers/get_transfer.py b/nlds_processors/transferers/get_transfer.py index fe9a169b..cd8e1ba7 100644 --- a/nlds_processors/transferers/get_transfer.py +++ b/nlds_processors/transferers/get_transfer.py @@ -71,9 +71,7 @@ def transfer(self, transaction_id: str, tenancy: str, access_key: str, self.log(f"{reason}, adding " f"{path_details.object_name} to retry list.", self.RK_LOG_INFO) - path_details.increment_retry( - retry_reason=reason - ) + path_details.retries.increment(reason=reason) self.append_and_send( path_details, rk_failed, body_json, list_type="retry" ) @@ -85,9 +83,7 @@ def transfer(self, transaction_id: str, tenancy: str, access_key: str, "buckets") self.log(f"{reason}. Adding {object_name} to retry list.", self.RK_LOG_ERROR) - path_details.increment_retry( - retry_reason=reason - ) + path_details.retries.increment(reason=reason) self.append_and_send( path_details, rk_failed, body_json, list_type="retry" ) @@ -110,9 +106,7 @@ def transfer(self, transaction_id: str, tenancy: str, access_key: str, "path is inaccessible.") self.log(f"{reason}. Adding to retry-list.", self.RK_LOG_INFO) - path_details.increment_retry( - retry_reason=reason - ) + path_details.retries.increment(reason=reason) self.append_and_send(path_details, rk_retry, body_json, list_type=FilelistType.retry) continue @@ -139,9 +133,7 @@ def transfer(self, transaction_id: str, tenancy: str, access_key: str, self.log(reason, self.RK_LOG_DEBUG) self.log(f"Exception encountered during download, adding " f"{object_name} to retry-list.", self.RK_LOG_INFO) - path_details.increment_retry( - retry_reason=reason - ) + path_details.retries.increment(reason=reason) self.append_and_send(path_details, rk_retry, body_json, list_type=FilelistType.retry) continue diff --git a/nlds_processors/transferers/put_transfer.py b/nlds_processors/transferers/put_transfer.py index dff1722f..850ce915 100644 --- a/nlds_processors/transferers/put_transfer.py +++ b/nlds_processors/transferers/put_transfer.py @@ -62,9 +62,7 @@ def transfer(self, transaction_id: str, tenancy: str, access_key: str, not self.check_path_access(item_path)): reason = (f"Path:{path_details.path} is inaccessible.") self.log(reason, self.RK_LOG_DEBUG) - path_details.increment_retry( - retry_reason=reason - ) + path_details.retries.increment(reason=reason) self.append_and_send( path_details, rk_retry, body_json, list_type="retry" ) @@ -97,7 +95,7 @@ def transfer(self, transaction_id: str, tenancy: str, access_key: str, reason = (f"Error uploading {path_details.path} to object " f"store: {e}.") self.log(f"{reason} Adding to retry list.", self.RK_LOG_ERROR) - path_details.increment_retry(retry_reason=reason) + path_details.retries.increment(reason=reason) self.append_and_send( path_details, rk_retry, body_json, list_type="retry" ) From e3f669116076324ef8709025845b1545422b8b7d Mon Sep 17 00:00:00 2001 From: Jack Leland Date: Fri, 3 Feb 2023 15:48:06 +0000 Subject: [PATCH 03/41] Finish refactor of Retries and add dictionary conversion --- nlds/details.py | 51 +++++++++++++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/nlds/details.py b/nlds/details.py index d17dccee..eae54449 100644 --- a/nlds/details.py +++ b/nlds/details.py @@ -1,6 +1,6 @@ from collections import namedtuple from enum import Enum -from typing import NamedTuple, Optional, List, Dict +from typing import NamedTuple, Optional, List, Dict, TypeVar from json import JSONEncoder from pathlib import Path import stat @@ -35,7 +35,6 @@ def __str__(self): "NOT_RECOGNISED", "UNINDEXED"][self.value] - class Retries(BaseModel): count: Optional[int] = 0 reasons: Optional[List[str]] = [] @@ -49,6 +48,24 @@ def reset(self) -> None: self.count = 0 self.reasons = [] + def to_dict(self) -> Dict: + return { + "retries": { + "count": self.count, + "reasons": self.reasons + } + } + + @classmethod + def from_dict(cls, dictionary: Dict[str, str]): + """Takes in a dictionary of the form generated by to_dict(), and returns + a Retries object representation of it.""" + return cls( + count=dictionary["retries"]["count"], + reasons=dictionary["retries"]["reasons"] + ) + +RetriesType = TypeVar('RetriesType', bound=Retries) class PathDetails(BaseModel): original_path: Optional[str] @@ -62,7 +79,7 @@ class PathDetails(BaseModel): modify_time: Optional[float] path_type: Optional[PathType] = PathType.UNINDEXED link_path: Optional[str] - retries: Retries + retries: Optional[RetriesType] = Retries() @property def path(self) -> str: @@ -83,23 +100,28 @@ def to_json(self): "path_type": self.path_type.value, "link_path": self.link_path, }, - "retries": self.retries.count, - "retry_reasons": self.retries.reasons + **self.retries.to_dict(), } @classmethod def from_dict(cls, json_contents: Dict[str, str]): - return cls(**json_contents['file_details'], - retries=json_contents["retries"], - retry_reasons=json_contents["retry_reasons"]) + if 'retries' in json_contents: + retries = Retries(**json_contents["retries"]) + else: + retries = Retries() + return cls( + **json_contents['file_details'], + retries=retries + ) @classmethod def from_path(cls, path: str): pd = cls(original_path=path) - return pd.stat() + pd.stat() + return pd @classmethod - def from_stat(cls, path: str, stat_result: NamedTuple): + def from_stat_result(cls, path: str, stat_result: NamedTuple): pd = cls(original_path=path) pd.stat(stat_result=stat_result) return pd @@ -151,12 +173,3 @@ def check_permissions(self, uid: int, gid: int, access=os.R_OK): path=self.original_path, stat_result=self.get_stat_result()) - # def increment_retry(self, retry_reason: str = None) -> None: - # # self.retries += 1 - # # if retry_reason: - # # self.retry_reasons.append(retry_reason) - # raise DeprecationWarning - - # def reset_retries(self) -> None: - # raise DeprecationWarning - From d6b0bb6c4a42b22e5e4d2371faa2aeae6138e460 Mon Sep 17 00:00:00 2001 From: Jack Leland Date: Fri, 3 Feb 2023 15:48:51 +0000 Subject: [PATCH 04/41] Update details unit tests to reflect recent refactor --- tests/nlds/test_details.py | 93 +++++++++++++++++++++++++++++++++++++- 1 file changed, 91 insertions(+), 2 deletions(-) diff --git a/tests/nlds/test_details.py b/tests/nlds/test_details.py index f66318e8..0e4648aa 100644 --- a/tests/nlds/test_details.py +++ b/tests/nlds/test_details.py @@ -3,9 +3,84 @@ import pytest -from nlds.details import PathDetails +from nlds.details import PathDetails, Retries from nlds.utils.permissions import check_permissions +def test_retries(): + retries = Retries() + + # Check that incrementing without a reason works + assert retries.count == 0 + assert len(retries.reasons) == 0 + retries.increment() + assert retries.count == 1 + assert len(retries.reasons) == 0 + + # Check that reset actually resets the count + retries.reset() + assert retries.count == 0 + assert len(retries.reasons) == 0 + + # Try incrementing with a reason + retries.increment(reason='Test retry') + assert retries.count == 1 + assert len(retries.reasons) == 1 + + # Try incrementing with another reason + retries.increment(reason='Different test reason') + assert retries.count == 2 + assert len(retries.reasons) == 2 + + # Check that reset does indeed work for a list of 2 reasons + retries.reset() + assert retries.count == 0 + assert len(retries.reasons) == 0 + + # See what happens if we try a non-string reason + retries.increment(reason=1) + assert retries.count == 1 + assert len(retries.reasons) == 1 + + # A None should be interpreted as 'not a reason' so shouldn't add to the + # reasons list + retries.increment(reason=None) + assert retries.count == 2 + assert len(retries.reasons) == 1 + + # A 0 should probably count as a reason, but currently doesn't + retries.increment(reason=0) + assert retries.count == 3 + assert len(retries.reasons) == 1 + + # Reset for testing dictionary conversion? Not necessary + # retries.reset() + # assert retries.count == 0 + # assert len(retries.reasons) == 0 + + # Convert to dict and check integrity of output. + # Should be in an outer dict called 'retries' + r_dict = retries.to_dict() + assert 'retries' in r_dict + + # Should contain a count and a reasons list + assert 'count' in r_dict['retries'] + assert isinstance(r_dict['retries']['count'], int) + + assert 'reasons' in r_dict['retries'] + assert isinstance(r_dict['retries']['reasons'], list) + + # Attempt to make a Retries object from the dictionary + new_retries = Retries.from_dict(r_dict) + assert new_retries.count == 3 + assert len(new_retries.reasons) == 1 + + # Attempt alternative constructor usage + alt_retries = Retries(**r_dict['retries']) + assert alt_retries.count == 3 + assert len(alt_retries.reasons) == 1 + + assert new_retries == alt_retries + def test_path_details(): # Attempt to make a path details object pd = PathDetails(original_path=__file__) @@ -30,9 +105,14 @@ def test_path_details(): # Test that creation from a stat_result is the same as the original object stat_result = Path(__file__).lstat() - pd_from_stat = PathDetails.from_stat(__file__, stat_result=stat_result) + pd_from_stat = PathDetails.from_stat_result(__file__, stat_result=stat_result) assert pd_from_stat == pd + # Similarly check that the from_path method creates an equivalent + # path_details object + pd_from_path = PathDetails.from_path(__file__) + assert pd_from_path == pd + # Check the approximated stat_result from the get_stat_result() method sr_from_pd = pd.get_stat_result() assert sr_from_pd.st_mode == stat_result.st_mode @@ -46,3 +126,12 @@ def test_path_details(): check_permissions(20, [100, ], path=__file__) == check_permissions(20, [100, ], stat_result=sr_from_pd) ) + + # Check that from_dict() and to_json() work + pd_json = pd.to_json() + pd_from_json = PathDetails.from_dict(pd_json) + assert pd == pd_from_json + + # Check contents of json? + assert "file_details" in pd_json + assert "retries" in pd_json \ No newline at end of file From d47d67a4b3a84eea1bac7d7f1701b67ed88b1a89 Mon Sep 17 00:00:00 2001 From: Jack Leland Date: Fri, 3 Feb 2023 18:47:11 +0000 Subject: [PATCH 05/41] Fix Retries refactor in multiple places --- nlds/rabbit/consumer.py | 2 +- nlds_processors/catalog/catalog_worker.py | 8 ++++---- nlds_processors/index.py | 4 ++-- nlds_processors/monitor/monitor.py | 2 +- nlds_processors/transferers/get_transfer.py | 2 +- nlds_processors/transferers/put_transfer.py | 2 +- tests/nlds_processors/test_index.py | 6 +++--- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/nlds/rabbit/consumer.py b/nlds/rabbit/consumer.py index c7b360e2..376af9c2 100644 --- a/nlds/rabbit/consumer.py +++ b/nlds/rabbit/consumer.py @@ -271,7 +271,7 @@ def send_pathlist(self, pathlist: List[PathDetails], routing_key: str, # Delay the retry message depending on how many retries have been # accumulated. All retries in a retry list _should_ be the same so # base it off of the first one. - delay = self.get_retry_delay(pathlist[0].retries) + delay = self.get_retry_delay(pathlist[0].retries.count) self.log(f"Adding {delay / 1000}s delay to retry. Should be sent at" f" {datetime.now() + timedelta(milliseconds=delay)}", self.RK_LOG_DEBUG) diff --git a/nlds_processors/catalog/catalog_worker.py b/nlds_processors/catalog/catalog_worker.py index 104c9402..29ac138c 100644 --- a/nlds_processors/catalog/catalog_worker.py +++ b/nlds_processors/catalog/catalog_worker.py @@ -230,7 +230,7 @@ def _catalog_put(self, body: dict, rk_origin: str) -> None: ) self.completelist.append(pd) except CatalogError as e: - if pd.retries > self.max_retries: + if pd.retries.count > self.max_retries: self.failedlist.append(pd) else: pd.retries.increment( @@ -373,7 +373,7 @@ def _catalog_get(self, body: dict, rk_origin: str) -> None: link_path = file.link_path ) except CatalogError as e: - if file_details.retries > self.max_retries: + if file_details.retries.count > self.max_retries: self.failedlist.append(file_details) else: self.retrylist.append(file_details) @@ -385,7 +385,7 @@ def _catalog_get(self, body: dict, rk_origin: str) -> None: self.completelist.append(new_file) except CatalogError as e: - if file_details.retries > self.max_retries: + if file_details.retries.count > self.max_retries: self.failedlist.append(file_details) else: file_details.retries.increment( @@ -504,7 +504,7 @@ def _catalog_del(self, body: dict, rk_origin: str) -> None: tag=tag ) except CatalogError as e: - if file_details.retries > self.max_retries: + if file_details.retries.count > self.max_retries: self.failedlist.append(file_details) else: file_details.retries.increment( diff --git a/nlds_processors/index.py b/nlds_processors/index.py index aff3768f..83db31ec 100644 --- a/nlds_processors/index.py +++ b/nlds_processors/index.py @@ -170,7 +170,7 @@ def index(self, raw_filelist: List[NamedTuple], rk_origin: str, # If any items has exceeded the maximum number of retries we add it # to the dead-end failed list - if path_details.retries > self.max_retries: + if path_details.retries.count > self.max_retries: # Append to failed list (in self) and send back to exchange if # the appropriate size. self.log(f"{path_details.path} has exceeded max retry count, " @@ -194,7 +194,7 @@ def index(self, raw_filelist: List[NamedTuple], rk_origin: str, # Increment retry counter and add to retry list reason = (f"Path:{path_details.path} is inaccessible.") self.log(reason, self.RK_LOG_DEBUG) - path_details.retries.increment(retry_reason=reason) + path_details.retries.increment(reason=reason) self.append_and_send( path_details, rk_retry, body_json, list_type="retry" ) diff --git a/nlds_processors/monitor/monitor.py b/nlds_processors/monitor/monitor.py index c26b0f70..17fe4651 100644 --- a/nlds_processors/monitor/monitor.py +++ b/nlds_processors/monitor/monitor.py @@ -140,7 +140,7 @@ def create_failed_file(self, try: failed_file = FailedFile( filepath=path_details.original_path, - reason=path_details.retry_reasons[-1], + reason=path_details.retries.reasons[-1], sub_record_id=sub_record.id, ) self.session.add(failed_file) diff --git a/nlds_processors/transferers/get_transfer.py b/nlds_processors/transferers/get_transfer.py index cd8e1ba7..9d54acca 100644 --- a/nlds_processors/transferers/get_transfer.py +++ b/nlds_processors/transferers/get_transfer.py @@ -55,7 +55,7 @@ def transfer(self, transaction_id: str, tenancy: str, access_key: str, rk_failed = ".".join([rk_origin, self.RK_TRANSFER_GET, self.RK_FAILED]) for path_details in filelist: - if path_details.retries > self.max_retries: + if path_details.retries.count > self.max_retries: self.append_and_send( path_details, rk_failed, body_json, list_type="failed" ) diff --git a/nlds_processors/transferers/put_transfer.py b/nlds_processors/transferers/put_transfer.py index 850ce915..506910dc 100644 --- a/nlds_processors/transferers/put_transfer.py +++ b/nlds_processors/transferers/put_transfer.py @@ -50,7 +50,7 @@ def transfer(self, transaction_id: str, tenancy: str, access_key: str, item_path = path_details.path # First check whether index item has failed too many times - if path_details.retries > self.max_retries: + if path_details.retries.count > self.max_retries: self.append_and_send( path_details, rk_failed, body_json, list_type="failed" ) diff --git a/tests/nlds_processors/test_index.py b/tests/nlds_processors/test_index.py index 6cc092b8..087d0288 100644 --- a/tests/nlds_processors/test_index.py +++ b/tests/nlds_processors/test_index.py @@ -6,7 +6,7 @@ from nlds.rabbit import publisher as publ import nlds.rabbit.statting_consumer as scons -from nlds.details import PathDetails +from nlds.details import PathDetails, Retries from nlds_processors.index import IndexerConsumer def mock_load_config(template_config): @@ -76,7 +76,7 @@ def test_index(monkeypatch, caplog, default_indexer, # Should work with any number of retries under the limit for i in range(default_indexer.max_retries): - test_filelist = [PathDetails(original_path="/test/", retries=i)] + test_filelist = [PathDetails(original_path="/test/", retries=Retries(count=i))] default_indexer.index(test_filelist, 'test', default_rmq_message_dict) assert len(default_indexer.completelist) == len(expected_filelist) @@ -90,7 +90,7 @@ def test_index(monkeypatch, caplog, default_indexer, # All files should be in failed list with any number of retries over the # limit for i in range(default_indexer.max_retries + 1, 10): - test_filelist = [PathDetails(original_path="/test/", retries=i)] + test_filelist = [PathDetails(original_path="/test/", retries=Retries(count=i))] default_indexer.index(test_filelist, 'test', default_rmq_message_dict) assert len(default_indexer.completelist) == 0 From 7e6c3235da2a0ff50572796cd108c23531223fa2 Mon Sep 17 00:00:00 2001 From: Neil Massey Date: Wed, 8 Feb 2023 14:21:33 +0000 Subject: [PATCH 06/41] Fixed badly formatted output string --- nlds/routers/files.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nlds/routers/files.py b/nlds/routers/files.py index 8c15eed1..592d28ca 100644 --- a/nlds/routers/files.py +++ b/nlds/routers/files.py @@ -102,7 +102,7 @@ async def get(transaction_id: UUID, response = FileResponse( uuid = transaction_id, msg = (f"GET transaction with transaction_id:{transaction_id} and " - "job label:{job_label} accepted for processing.") + f"job label:{job_label} accepted for processing.") ) contents = [filepath, ] # create the message dictionary - do this here now as it's more transparent @@ -172,7 +172,7 @@ async def put(transaction_id: UUID, response = FileResponse( uuid = transaction_id, msg = (f"GETLIST transaction with transaction_id:{transaction_id} and " - "job_label:{job_label} accepted for processing.") + f"job_label:{job_label} accepted for processing.") ) # Convert filepath or filelist to lists @@ -262,7 +262,7 @@ async def put(transaction_id: UUID, response = FileResponse( uuid = transaction_id, msg = (f"PUT transaction with transaction_id:{transaction_id} and " - "job_label:{job_label} accepted for processing.\n") + f"job_label:{job_label} accepted for processing.\n") ) # create the message dictionary - do this here now as it's more transparent routing_key = f"{RMQP.RK_ROOT}.{RMQP.RK_ROUTE}.{RMQP.RK_PUT}" From 695e09a06fc349ddfbad6e0461b07b7e4368fa65 Mon Sep 17 00:00:00 2001 From: Neil Massey Date: Wed, 8 Feb 2023 14:21:57 +0000 Subject: [PATCH 07/41] Add tags to PUT and alter / add tags using meta command --- nlds/routers/list.py | 9 ++-- nlds/routers/meta.py | 2 +- nlds_processors/catalog/catalog.py | 55 ++++++++++++++++++++--- nlds_processors/catalog/catalog_models.py | 12 +++++ nlds_processors/catalog/catalog_worker.py | 31 ++++++++----- test_run/test_run.rc | 11 ++--- 6 files changed, 95 insertions(+), 25 deletions(-) diff --git a/nlds/routers/list.py b/nlds/routers/list.py index e35d0137..4ba8925a 100644 --- a/nlds/routers/list.py +++ b/nlds/routers/list.py @@ -70,16 +70,19 @@ async def get(token: str = Depends(authenticate_token), meta_dict[RMQP.MSG_TRANSACT_ID] = transaction_id if (tag): + print(tag) tag_dict = {} # convert the string into a dictionary + # try: try: # strip whitespace and "{" "}" symbolsfirst tag_list = (tag.replace(" ","").replace("{", "").replace("}", "") ).split(",") for tag_i in tag_list: - tag_kv = tag_i.split(":") - tag_dict[tag_kv[0]] = tag_kv[1] - except: # what exception might be raised here? + if len(tag_i) > 0: + tag_kv = tag_i.split(":") + tag_dict[tag_kv[0]] = tag_kv[1] + except IndexError: # what exception might be raised here? response_error = ResponseError( loc = ["holdings", "get"], msg = "tag cannot be processed.", diff --git a/nlds/routers/meta.py b/nlds/routers/meta.py index 43c9b45c..2097ceed 100644 --- a/nlds/routers/meta.py +++ b/nlds/routers/meta.py @@ -53,7 +53,7 @@ async def post(metamodel: MetaModel, group: str = Depends(authenticate_group), label: Optional[str] = None, holding_id: Optional[int] = None, - tag: Optional[str] = None, + tag: Optional[str] = None ): # create the message dictionary diff --git a/nlds_processors/catalog/catalog.py b/nlds_processors/catalog/catalog.py index 445b4b47..f631f76d 100644 --- a/nlds_processors/catalog/catalog.py +++ b/nlds_processors/catalog/catalog.py @@ -1,6 +1,7 @@ # SQLalchemy imports from sqlalchemy import func, Enum -from sqlalchemy.exc import IntegrityError, OperationalError, ArgumentError +from sqlalchemy.exc import IntegrityError, OperationalError, ArgumentError, \ + NoResultFound from nlds_processors.catalog.catalog_models import CatalogBase, File, Holding,\ Location, Transaction, Storage, Checksum, Tag @@ -141,9 +142,17 @@ def modify_holding(self, if new_tags: for k in new_tags: - # create_tag takes a key and value - tag = self.create_tag(holding, k, new_tags[k]) - self.session.flush() + # if the tag exists then modify it, if it doesn't then create it + try: + # get + tag = self.get_tag(holding, k) + except CatalogError: + # create + tag = self.create_tag(holding, k, new_tags[k]) + else: + # modify + tag = self.modify_tag(holding, k, new_tags[k]) + self.session.flush() return holding @@ -377,6 +386,7 @@ def create_file(self, ) return new_file + def delete_files(self, user: str, group: str, @@ -404,6 +414,7 @@ def delete_files(self, err_msg = f"File with original_path:{path} could not be deleted" raise CatalogError(err_msg) + def get_location(self, file: File, storage_type: Enum) -> Location: @@ -465,8 +476,42 @@ def create_tag(self, value = value, holding_id = holding.id ) + self.session.add(tag) + self.session.flush() # flush to generate tag.id except (IntegrityError, KeyError): raise CatalogError( f"Tag could not be added to holding:{holding.label}" ) - return tag \ No newline at end of file + return tag + + + def get_tag(self, holding: Holding, key: str): + """Get the tag with a specific key""" + assert(self.session != None) + try: + tag = self.session.query(Tag).filter( + Tag.key == key, + Tag.holding_id == holding.id + ).one() # uniqueness constraint guarantees only one + except (NoResultFound, KeyError): + raise CatalogError( + f"Tag with key:{key} not found" + ) + return tag + + + def modify_tag(self, holding: Holding, key: str, value: str): + """Modify a tag that has the key, with a new value. + Tag has to exist, current value will be overwritten.""" + assert(self.session != None) + try: + tag = self.session.query(Tag).filter( + Tag.key == key, + Tag.holding_id == holding.id + ).one() # uniqueness constraint guarantees only one + tag.value = value + except (NoResultFound, KeyError): + raise CatalogError( + f"Tag with key:{key} not found" + ) + return tag diff --git a/nlds_processors/catalog/catalog_models.py b/nlds_processors/catalog/catalog_models.py index 284e6bc4..b276c73d 100644 --- a/nlds_processors/catalog/catalog_models.py +++ b/nlds_processors/catalog/catalog_models.py @@ -28,6 +28,18 @@ class Holding(CatalogBase): transactions = relationship("Transaction", cascade="delete, delete-orphan") # label must be unique per user __table_args__ = (UniqueConstraint('label', 'user'),) + # return the tags as a dictionary + def get_tags(self): + tags = {} + for t in self.tags: + tags[t.key] = t.value + return tags + # return the transaction ids as a list + def get_transaction_ids(self): + t_ids = [] + for t in self.transactions: + t_ids.append(t.transaction_id) + return t_ids class Transaction(CatalogBase): diff --git a/nlds_processors/catalog/catalog_worker.py b/nlds_processors/catalog/catalog_worker.py index 29ac138c..0753bd0c 100644 --- a/nlds_processors/catalog/catalog_worker.py +++ b/nlds_processors/catalog/catalog_worker.py @@ -142,7 +142,11 @@ def _catalog_put(self, body: dict, rk_origin: str) -> None: except KeyError: holding_id = None - ######## TAGS TAGS TAGS ######## + # get any tags that exist + try: + tags = body[self.MSG_META][self.MSG_TAG] + except KeyError: + tags = None # start the database transactions self.catalog.start_session() @@ -239,6 +243,16 @@ def _catalog_put(self, body: dict, rk_origin: str) -> None: self.retrylist.append(pd) self.log(e.message, RMQC.RK_LOG_ERROR) continue + + # add the tags - if the tag already exists then don't add it or modify + # it, with the reasoning that the user can change it with the `meta` + # command. + for k in tags: + try: + tag = self.catalog.get_tag(holding, k) + except CatalogError: # tag's key not found so create + self.catalog.create_tag(holding, k, tags[k]) + # stop db transitions and commit self.catalog.save() self.catalog.end_session() @@ -618,15 +632,10 @@ def _catalog_list(self, body: dict, properties: Header) -> None: "label": h.label, "user": h.user, "group": h.group, - "tags": h.tags, + "tags": h.get_tags(), + "transactions": h.get_transaction_ids(), "date": t.ingest_time.isoformat() } - # add the transaction ids: - t_ids = [] - for t in h.transactions: - t_ids.append(t.transaction_id) - - ret_dict["transactions"] = t_ids ret_list.append(ret_dict) # add the return list to successfully completed holding listings body[self.MSG_DATA][self.MSG_HOLDING_LIST] = ret_list @@ -927,11 +936,12 @@ def _catalog_meta(self, body: dict, properties: Header) -> None: old_meta = { "label": holding.label, - "tags": {t.key:t.value for t in holding.tags} + "tags": holding.get_tags(), } holding = self.catalog.modify_holding( holding, new_label, new_tag ) + self.catalog.save() except CatalogError as e: # failed to get the holdings - send a return message saying so self.log(e.message, self.RK_LOG_ERROR) @@ -946,7 +956,7 @@ def _catalog_meta(self, body: dict, properties: Header) -> None: "old_meta" : old_meta, "new_meta" : { "label": holding.label, - "tags": holding.tags + "tags": holding.get_tags() } } body[self.MSG_DATA][self.MSG_HOLDING_LIST] = [ret_dict] @@ -955,7 +965,6 @@ def _catalog_meta(self, body: dict, properties: Header) -> None: self.RK_LOG_DEBUG ) - self.catalog.save() self.catalog.end_session() # return message to complete RPC diff --git a/test_run/test_run.rc b/test_run/test_run.rc index a2d9455f..4eb843d5 100644 --- a/test_run/test_run.rc +++ b/test_run/test_run.rc @@ -35,8 +35,9 @@ focus # right pos 4 # create the logger -screen -t "logger" -exec "$PYTHON_DIR/python" "$NLDS_PROC/logger.py" +# run the server via uvicorn +screen -t "server" +exec "$PYTHON_DIR/uvicorn" "nlds.main:nlds" "--reload" "--log-level=trace" "--port=8000" focus left split focus @@ -56,6 +57,6 @@ split focus # left pos 4 -# run the server via uvicorn -screen -t "server" -exec "$PYTHON_DIR/uvicorn" "nlds.main:nlds" "--reload" "--log-level=trace" "--port=8000" +screen -t "logger" +#exec "$PYTHON_DIR/python" "$NLDS_PROC/logger.py" + From 78002ebd1a27dcbd1b9fdc0dfd434ea1cc8a028f Mon Sep 17 00:00:00 2001 From: Jack Leland Date: Thu, 9 Feb 2023 13:20:17 +0000 Subject: [PATCH 08/41] Add a General section to the server config This is done so as to allow a general/default retry delays tuple to be specified. --- nlds/rabbit/publisher.py | 32 +++++++++++++++++++++++++------- nlds/server_config.py | 2 ++ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/nlds/rabbit/publisher.py b/nlds/rabbit/publisher.py index abd56c0c..f21a4bea 100644 --- a/nlds/rabbit/publisher.py +++ b/nlds/rabbit/publisher.py @@ -10,25 +10,31 @@ import sys from datetime import datetime, timedelta -from uuid import UUID import json import logging from logging.handlers import TimedRotatingFileHandler from typing import Dict, List import pathlib -from collections import namedtuple +import collections import pika from pika.exceptions import AMQPConnectionError, UnroutableError, ChannelWrongStateError from retry import retry from ..server_config import ( - LOGGING_CONFIG_ROLLOVER, load_config, LOGGING_CONFIG_FILES, LOGGING_CONFIG_STDOUT, - RABBIT_CONFIG_SECTION, LOGGING_CONFIG_SECTION, LOGGING_CONFIG_LEVEL, - LOGGING_CONFIG_STDOUT_LEVEL, LOGGING_CONFIG_FORMAT, LOGGING_CONFIG_ENABLE + load_config, + GENERAL_CONFIG_SECTION, + RABBIT_CONFIG_SECTION, + LOGGING_CONFIG_SECTION, + LOGGING_CONFIG_ROLLOVER, + LOGGING_CONFIG_FILES, + LOGGING_CONFIG_STDOUT, + LOGGING_CONFIG_LEVEL, + LOGGING_CONFIG_STDOUT_LEVEL, + LOGGING_CONFIG_FORMAT, + LOGGING_CONFIG_ENABLE, ) from ..errors import RabbitRetryError -from ..details import PathDetails logger = logging.getLogger("nlds.root") @@ -145,6 +151,10 @@ def __init__(self, name="publisher", setup_logging_fl=False): # Get rabbit-specific section of config file self.whole_config = load_config() self.config = self.whole_config[RABBIT_CONFIG_SECTION] + if GENERAL_CONFIG_SECTION in self.general_config: + self.general_config = self.whole_config[GENERAL_CONFIG_SECTION] + else: + self.general_config = dict() # Set name for logging purposes self.name = name @@ -163,7 +173,15 @@ def __init__(self, name="publisher", setup_logging_fl=False): self.connection = None self.channel = None - self.retry_delays = self.DEFAULT_RETRY_DELAYS + try: + # Do some basic verification of the general retry delays. + self.retry_delays = self.general_config[self.RETRY_DELAYS] + assert (isinstance(self.retry_delays, collections.Sequence) + and not isinstance(self.retry_delays, str)) + assert len(self.retry_delays) > 0 + assert isinstance(self.retry_delays[0], int) + except (KeyError, TypeError, AssertionError): + self.retry_delays = self.DEFAULT_RETRY_DELAYS if setup_logging_fl: self.setup_logging() diff --git a/nlds/server_config.py b/nlds/server_config.py index a92d9647..07d9e18a 100644 --- a/nlds/server_config.py +++ b/nlds/server_config.py @@ -31,6 +31,8 @@ LOGGING_CONFIG_FILES = "log_files" LOGGING_CONFIG_ROLLOVER = "rollover" +GENERAL_CONFIG_SECTION = "general" + # Defines the compulsory server config file sections CONFIG_SCHEMA = ( (AUTH_CONFIG_SECTION, ("authenticator_backend", )), From 3ff14eb4ceb8c81670cd565a97417201fad37cca Mon Sep 17 00:00:00 2001 From: Jack Leland Date: Thu, 9 Feb 2023 13:32:12 +0000 Subject: [PATCH 09/41] Fix typo in publisher general config logic --- nlds/rabbit/publisher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nlds/rabbit/publisher.py b/nlds/rabbit/publisher.py index f21a4bea..f7ebfdc2 100644 --- a/nlds/rabbit/publisher.py +++ b/nlds/rabbit/publisher.py @@ -151,7 +151,7 @@ def __init__(self, name="publisher", setup_logging_fl=False): # Get rabbit-specific section of config file self.whole_config = load_config() self.config = self.whole_config[RABBIT_CONFIG_SECTION] - if GENERAL_CONFIG_SECTION in self.general_config: + if GENERAL_CONFIG_SECTION in self.whole_config: self.general_config = self.whole_config[GENERAL_CONFIG_SECTION] else: self.general_config = dict() From 09a1c8d4c2bcb97b14e8d77c2503a653a016b60e Mon Sep 17 00:00:00 2001 From: Jack Leland Date: Thu, 9 Feb 2023 16:53:55 +0000 Subject: [PATCH 10/41] Fix typo in successful response strings --- nlds/routers/files.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nlds/routers/files.py b/nlds/routers/files.py index 8c15eed1..928c551c 100644 --- a/nlds/routers/files.py +++ b/nlds/routers/files.py @@ -102,7 +102,7 @@ async def get(transaction_id: UUID, response = FileResponse( uuid = transaction_id, msg = (f"GET transaction with transaction_id:{transaction_id} and " - "job label:{job_label} accepted for processing.") + f"job_label:{job_label} accepted for processing.") ) contents = [filepath, ] # create the message dictionary - do this here now as it's more transparent @@ -172,7 +172,7 @@ async def put(transaction_id: UUID, response = FileResponse( uuid = transaction_id, msg = (f"GETLIST transaction with transaction_id:{transaction_id} and " - "job_label:{job_label} accepted for processing.") + f"job_label:{job_label} accepted for processing.") ) # Convert filepath or filelist to lists @@ -262,7 +262,7 @@ async def put(transaction_id: UUID, response = FileResponse( uuid = transaction_id, msg = (f"PUT transaction with transaction_id:{transaction_id} and " - "job_label:{job_label} accepted for processing.\n") + f"job_label:{job_label} accepted for processing.\n") ) # create the message dictionary - do this here now as it's more transparent routing_key = f"{RMQP.RK_ROOT}.{RMQP.RK_ROUTE}.{RMQP.RK_PUT}" From ff4475860b36c649eb3936e39b956001bf03065b Mon Sep 17 00:00:00 2001 From: Jack Leland Date: Fri, 10 Feb 2023 17:07:08 +0000 Subject: [PATCH 11/41] Add retries section strings and rename RETRY_COUNT --- nlds/details.py | 11 ++++++----- nlds/rabbit/publisher.py | 6 +++++- nlds/routers/status.py | 2 +- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/nlds/details.py b/nlds/details.py index eae54449..ea949001 100644 --- a/nlds/details.py +++ b/nlds/details.py @@ -9,6 +9,7 @@ from pydantic import BaseModel from .utils.permissions import check_permissions +from .rabbit.publisher import RabbitMQPublisher as RMQP # Patch the JSONEncoder so that a custom json serialiser can be run instead of # of the default, if one exists. This patches for ALL json.dumps calls. @@ -50,9 +51,9 @@ def reset(self) -> None: def to_dict(self) -> Dict: return { - "retries": { - "count": self.count, - "reasons": self.reasons + RMQP.MSG_RETRIES: { + RMQP.MSG_RETRIES_COUNT: self.count, + RMQP.MSG_RETRIES_REASONS: self.reasons } } @@ -61,8 +62,8 @@ def from_dict(cls, dictionary: Dict[str, str]): """Takes in a dictionary of the form generated by to_dict(), and returns a Retries object representation of it.""" return cls( - count=dictionary["retries"]["count"], - reasons=dictionary["retries"]["reasons"] + count=dictionary[RMQP.MSG_RETRIES][RMQP.MSG_RETRIES_COUNT], + reasons=dictionary[RMQP.MSG_RETRIES][RMQP.MSG_RETRIES_REASONS], ) RetriesType = TypeVar('RetriesType', bound=Retries) diff --git a/nlds/rabbit/publisher.py b/nlds/rabbit/publisher.py index f7ebfdc2..cf9e0145 100644 --- a/nlds/rabbit/publisher.py +++ b/nlds/rabbit/publisher.py @@ -128,9 +128,13 @@ class RabbitMQPublisher(): MSG_FAILURE = "failure" MSG_USER_QUERY = "user_query" MSG_GROUP_QUERY = "group_query" - MSG_RETRY_COUNT = "retry_count" + MSG_RETRY_COUNT_QUERY = "retry_count" MSG_RECORD_LIST = "records" + MSG_RETRIES = "retries" + MSG_RETRIES_COUNT = "count" + MSG_RETRIES_REASONS = "reasons" + MSG_TYPE = "type" MSG_TYPE_STANDARD = "standard" MSG_TYPE_LOG = "log" diff --git a/nlds/routers/status.py b/nlds/routers/status.py index 1a3d9ae8..04844f5a 100644 --- a/nlds/routers/status.py +++ b/nlds/routers/status.py @@ -127,7 +127,7 @@ async def get(token: str = Depends(authenticate_token), RMQP.MSG_JOB_LABEL: job_label, RMQP.MSG_STATE: state, RMQP.MSG_SUB_ID: sub_id, - RMQP.MSG_RETRY_COUNT: retry_count, + RMQP.MSG_RETRY_COUNT_QUERY: retry_count, RMQP.MSG_USER_QUERY: user, RMQP.MSG_GROUP_QUERY: group, RMQP.MSG_API_ACTION: api_action, From bb9de4cb631ee754a3affa22af1c95307e578f73 Mon Sep 17 00:00:00 2001 From: Jack Leland Date: Fri, 10 Feb 2023 17:09:33 +0000 Subject: [PATCH 12/41] Add Transaction-level Retries into Workflow The callback wrapper now implements the transaction-level retries made possible by the refactoring of Retries outside of the PathDetails object. Some things to note: - All messages that fail in the accepted number of ways denoted in the try-catch block in teh callback wrapper will be retried. We may in the future want to expand this to all Exceptions and expand what throws errors within the callback so it is applied more widely. - Only the final reason is stored alonside the FailedFile record in the monitoring database, which can be either a File-level Retry Reason or a Transaction-level Retry Reason. We may in the future want to store more than one reason if they exist but this will do for now. - All retries are reset upon successful completion of a callback, both FRs and TRs --- nlds/rabbit/consumer.py | 155 ++++++++++++++++++---- nlds/routers/files.py | 14 +- nlds_processors/monitor/monitor.py | 21 ++- nlds_processors/monitor/monitor_worker.py | 22 ++- 4 files changed, 172 insertions(+), 40 deletions(-) diff --git a/nlds/rabbit/consumer.py b/nlds/rabbit/consumer.py index 376af9c2..a2628e58 100644 --- a/nlds/rabbit/consumer.py +++ b/nlds/rabbit/consumer.py @@ -18,6 +18,8 @@ from datetime import datetime, timedelta import uuid import json +from json.decoder import JSONDecodeError +from urllib3.exceptions import HTTPError from pika.exceptions import StreamLostError, AMQPConnectionError from pika.channel import Channel @@ -33,7 +35,7 @@ LOGGING_CONFIG_SECTION, LOGGING_CONFIG_STDOUT, RABBIT_CONFIG_QUEUES, LOGGING_CONFIG_STDOUT_LEVEL, RABBIT_CONFIG_QUEUE_NAME, LOGGING_CONFIG_ROLLOVER) -from ..details import PathDetails +from ..details import PathDetails, Retries from ..errors import RabbitRetryError logger = logging.getLogger("nlds.root") @@ -89,6 +91,9 @@ def has_name(cls, name): def get_final_states(cls): return cls.TRANSFER_GETTING, cls.TRANSFER_PUTTING, cls.FAILED + def to_json(self): + return self.value + class RabbitMQConsumer(ABC, RabbitMQPublisher): DEFAULT_QUEUE_NAME = "test_q" DEFAULT_ROUTING_KEY = "test" @@ -264,7 +269,11 @@ def send_pathlist(self, pathlist: List[PathDetails], routing_key: str, delay = 0 body_json[self.MSG_DETAILS][self.MSG_RETRY] = False if mode == FilelistType.processed: - # Reset the retries upon successful indexing. + # Reset the retries (both transaction-level and file-level) upon + # successful completion of processing. + trans_retries = Retries.from_dict(body_json) + trans_retries.reset() + body_json.update(trans_retries.to_dict()) for path_details in pathlist: path_details.retries.reset() elif mode == FilelistType.retry: @@ -338,6 +347,115 @@ def setup_logging(self, enable=False, log_level: str = None, return super().setup_logging(enable, log_level, log_format, add_stdout_fl, stdout_log_level, log_files, log_rollover) + def _log_errored_transaction(self, body, exception): + """Log message which has failed at some point in its callback""" + if self.print_tracebacks_fl: + tb = traceback.format_exc() + self.log("Printing traceback of error: ", self.RK_LOG_DEBUG) + self.log(tb, self.RK_LOG_DEBUG) + self.log( + f"Encountered error in message callback ({exception}), sending" + " to logger.", self.RK_LOG_ERROR, exc_info=exception + ) + self.log( + f"Failed message content: {body}", + self.RK_LOG_DEBUG + ) + + def _handle_expected_error(self, body, routing_key, original_error): + """Handle the workflow of an expected error - attempt to retry the + transaction or fail it as apparopriate. Given we don't know exactly what + is wrong with the message we need to be quite defensive with error + handling here so as to avoid breaking the consumption loop. + + """ + # First we log the expected error + self._log_errored_transaction(body, original_error) + + # Then we try to parse its source and retry information from the message + # body. + retries = None + try: + # First try to parse message body for retry information + body_json = json.loads(body) + retries = Retries.from_dict(body_json) + except (JSONDecodeError, KeyError) as e: + self.log("Could not retrieve failed message retry information " + f"{e}, will now attempt to fail message in monitoring.", + self.RK_LOG_INFO) + try: + # Get message source from routing key, if possible + rk_parts = self.split_routing_key(routing_key) + rk_source = rk_parts[0] + except Exception as e: + self.log("Could not retrieve routing key source, reverting to " + "default NLDS value.", self.RK_LOG_WARNING) + rk_source = self.RK_ROOT + + monitoring_rk = ".".join([rk_source, + self.RK_MONITOR_PUT, + self.RK_START]) + + if retries is not None and retries.count <= self.max_retries: + # Retry the job + self.log(f"Retrying errored job with routing key {routing_key}", + self.RK_LOG_INFO) + try: + self._retry_transaction(body_json, retries, routing_key, + monitoring_rk, original_error) + except Exception as e: + self.log(f"Failed attempt to retry transaction that failed " + "during callback. Error: {e}.", + self.RK_LOG_WARNING) + # Fail the job if at any point the attempt to retry fails. + self._fail_transaction(body_json, monitoring_rk) + else: + # Fail the job + self._fail_transaction(body_json, monitoring_rk) + + def _fail_transaction(self, body_json, monitoring_rk): + """Attempt to mark transaction as failed in monitoring db""" + try: + # Send message to monitoring to keep track of state + body_json[self.MSG_DETAILS][self.MSG_STATE] = State.FAILED + body_json[self.MSG_DETAILS][self.MSG_RETRY] = False + self.publish_message(monitoring_rk, body_json) + except Exception as e: + # If this fails there's not much we can do at this stage... + # TODO: might be worth figuring out a way of just extracting the + # transaction id and failing the job from that? If it's gotten to + # this point and failed then + self.log("Failed attempt to mark transaction as failed in " + "monitoring.", self.RK_LOG_WARNING) + self.log(f"Exception that arose during attempt to mark job as " + f"failed: {e}", self.RK_LOG_DEBUG) + self.log(f"Message that couldn't be failed: " + f"{json.dumps(body_json)}", self.RK_LOG_DEBUG) + + def _retry_transaction( + self, + body_json: Dict[str, str], + retries: Retries, + original_rk: str, + monitoring_rk: str, + error: Exception + ) -> None: + """Attempt to retry the message with a retry delay, back to the original + routing_key""" + # Delay the retry message depending on how many retries have been + # accumulated - using simply the transaction-level retries + retries.increment(reason=f"Exception during callback: {error}") + body_json.update(retries.to_dict()) + delay = self.get_retry_delay(retries.count) + self.log(f"Adding {delay / 1000}s delay to retry. Should be sent at" + f" {datetime.now() + timedelta(milliseconds=delay)}", + self.RK_LOG_DEBUG) + body_json[self.MSG_DETAILS][self.MSG_RETRY] = True + + # Send to original routing key (i.e. retry it) with the requisite delay + # and also update the monitoring db. + self.publish_message(original_rk, body_json, delay=delay) + self.publish_message(monitoring_rk, body_json) @staticmethod def _acknowledge_message(channel: Channel, delivery_tag: str) -> None: @@ -400,33 +518,12 @@ def _wrapped_callback(self, ch: Channel, method: Method, properties: Header, KeyError, PermissionError, RabbitRetryError, - ) as e: - try: - # Attempt to mark job as failed in monitoring db - # TODO: this probably isn't the best way of doing this! - rk_parts = self.split_routing_key(method.routing_key) - body_json = json.loads(body) - - # Send message to monitoring to keep track of state - monitoring_rk = ".".join([rk_parts[0], - self.RK_MONITOR_PUT, - self.RK_START]) - body_json[self.MSG_DETAILS][self.MSG_STATE] = State.FAILED - self.publish_message(monitoring_rk, body_json) - except: - self.log("Failed attempt to mark job as failed in monitoring.", - self.RK_LOG_WARNING) - if self.print_tracebacks_fl: - tb = traceback.format_exc() - self.log(tb, self.RK_LOG_DEBUG) - self.log( - f"Encountered error ({e}), sending to logger.", - self.RK_LOG_ERROR, exc_info=e - ) - self.log( - f"Failed message content: {body}", - self.RK_LOG_DEBUG - ) + JSONDecodeError, + HTTPError, + ) as original_error: + self._handle_expected_error(body, method.routing_key, original_error) + except Exception as e: + self._log_errored_transaction(body, e) finally: # Ack message only if it has failed in the limited number of ways # above, otherwise the exception is reraised and breaks the diff --git a/nlds/routers/files.py b/nlds/routers/files.py index 928c551c..bfc5ec17 100644 --- a/nlds/routers/files.py +++ b/nlds/routers/files.py @@ -20,9 +20,8 @@ from ..routers import rabbit_publisher from ..rabbit.publisher import RabbitMQPublisher as RMQP -from . import rpc_publisher from ..errors import ResponseError -from ..details import PathDetails +from ..details import PathDetails, Retries from ..authenticators.authenticate_methods import authenticate_token, \ authenticate_group, \ authenticate_user @@ -124,8 +123,9 @@ async def get(transaction_id: UUID, RMQP.MSG_DATA: { # Convert to PathDetails for JSON serialisation RMQP.MSG_FILELIST: [PathDetails(original_path=item) for item in contents], - }, - RMQP.MSG_TYPE: RMQP.MSG_TYPE_STANDARD + }, + **Retries().to_dict(), + RMQP.MSG_TYPE: RMQP.MSG_TYPE_STANDARD, } rabbit_publisher.publish_message(routing_key, msg_dict) return JSONResponse(status_code = status.HTTP_202_ACCEPTED, @@ -198,7 +198,8 @@ async def put(transaction_id: UUID, # Convert to PathDetails for JSON serialisation RMQP.MSG_FILELIST: [PathDetails(original_path=item) for item in contents], }, - RMQP.MSG_TYPE: RMQP.MSG_TYPE_STANDARD + **Retries().to_dict(), + RMQP.MSG_TYPE: RMQP.MSG_TYPE_STANDARD, } # add the metadata meta_dict = {} @@ -283,7 +284,8 @@ async def put(transaction_id: UUID, # Convert to PathDetails for JSON serialisation RMQP.MSG_FILELIST: [PathDetails(original_path=item) for item in contents], }, - RMQP.MSG_TYPE: RMQP.MSG_TYPE_STANDARD + **Retries().to_dict(), + RMQP.MSG_TYPE: RMQP.MSG_TYPE_STANDARD, } # add the metadata meta_dict = {} diff --git a/nlds_processors/monitor/monitor.py b/nlds_processors/monitor/monitor.py index 17fe4651..0aab00d7 100644 --- a/nlds_processors/monitor/monitor.py +++ b/nlds_processors/monitor/monitor.py @@ -1,3 +1,5 @@ +from typing import List + from sqlalchemy import create_engine, func, Enum from sqlalchemy.exc import ArgumentError, IntegrityError, OperationalError from sqlalchemy.orm import Session @@ -136,11 +138,26 @@ def create_sub_record(self, def create_failed_file(self, sub_record: SubRecord, - path_details: PathDetails) -> FailedFile: + path_details: PathDetails, + reason: str = None) -> FailedFile: + """Creates a FailedFile object for the monitoring database. Requires the + input of the parent SubRecord and the PathDetails object of the failed + file in question. Optionally requires a reason str, which will otherwise + be attempted to be taken from the PathDetails object. If no reason can + be found then a MonitorError will be raised. + """ + if reason is None: + if len(path_details.retries.reasons) <= 0: + raise MonitorError( + f"FailedFile for sub_record_id:{sub_record.id} could not be " + "added to the database as no failure reason was supplied. " + ) + else: + reason = path_details.retries.reasons[-1] try: failed_file = FailedFile( filepath=path_details.original_path, - reason=path_details.retries.reasons[-1], + reason=reason, sub_record_id=sub_record.id, ) self.session.add(failed_file) diff --git a/nlds_processors/monitor/monitor_worker.py b/nlds_processors/monitor/monitor_worker.py index 7b9aa6c5..205bb93a 100644 --- a/nlds_processors/monitor/monitor_worker.py +++ b/nlds_processors/monitor/monitor_worker.py @@ -33,6 +33,7 @@ from nlds.rabbit.consumer import RabbitMQConsumer as RMQC from nlds.rabbit.consumer import State +from nlds.details import Retries from nlds_processors.monitor.monitor import Monitor, MonitorError from nlds_processors.monitor.monitor_models import orm_to_dict from nlds_processors.db_mixin import DBError @@ -146,6 +147,14 @@ def _monitor_put(self, body: Dict[str, str]) -> None: except KeyError: self.log("No retry_fl found in message, assuming false.", self.RK_LOG_DEBUG) + + # Get the transaction-level retry + try: + trans_retries = Retries.from_dict(body) + except KeyError: + self.log("No retries found in message, continuing with an empty ", + "Retries object.", self.RK_LOG_DEBUG) + trans_retries = Retries() # start the database transactions self.monitor.start_session() @@ -214,8 +223,15 @@ def _monitor_put(self, body: Dict[str, str]) -> None: # Create failed_files if necessary if state == State.FAILED: try: - for path_details in filelist: - self.monitor.create_failed_file(srec, path_details) + # Passing reason as None to create_failed_file will default to + # to the last reason in the PathDetails object retries section. + reason = None + for pd in filelist: + # Check which was the final reason for failure and store + # that as the failure reason for the FailedFile. + if len(trans_retries.reasons) > len(pd.retries.reasons): + reason = trans_retries.reasons[-1] + self.monitor.create_failed_file(srec, pd, reason=reason) except MonitorError as e: self.log(e, self.RK_LOG_ERROR) @@ -346,7 +362,7 @@ def _monitor_get(self, body: Dict[str, str], properties: Header) -> None: # get the desired retry_count from the DETAILS section of the message try: - retry_count = int(body[self.MSG_DETAILS][self.MSG_RETRY_COUNT]) + retry_count = int(body[self.MSG_DETAILS][self.MSG_RETRY_COUNT_QUERY]) except (KeyError, TypeError): self.log("Transaction sub-id not in message, continuing without.", self.RK_LOG_INFO) From c848daeeb2834984d3b5680bf9155f0ad5571275 Mon Sep 17 00:00:00 2001 From: Jack Leland Date: Fri, 10 Feb 2023 17:18:31 +0000 Subject: [PATCH 13/41] Update pytest fixture message to reflect new Retries section --- tests/conftest.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 969f6856..4ded5987 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,7 +7,7 @@ import pytest from nlds.rabbit.publisher import RabbitMQPublisher as RMQP -from nlds.details import PathDetails +from nlds.details import PathDetails, Retries TEMPLATE_CONFIG_PATH = os.path.join(os.path.dirname(__file__), @@ -52,6 +52,7 @@ def default_rmq_body(test_uuid): # Convert to PathDetails for JSON serialisation RMQP.MSG_FILELIST: [PathDetails(original_path="item_path"),], }, + **Retries().to_dict(), RMQP.MSG_TYPE: RMQP.MSG_TYPE_STANDARD } return json.dumps(msg_dict) From c6379cc512e3afd55a9af96b56a3117027b20af7 Mon Sep 17 00:00:00 2001 From: Jack Leland Date: Fri, 10 Feb 2023 17:32:09 +0000 Subject: [PATCH 14/41] Add a bespoke CallbackError exception for triggering a retry --- nlds/errors.py | 5 +++++ nlds/rabbit/consumer.py | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/nlds/errors.py b/nlds/errors.py index 9463a0fc..e99c8bf0 100644 --- a/nlds/errors.py +++ b/nlds/errors.py @@ -22,3 +22,8 @@ class RabbitRetryError(BaseException): def __init__(self, *args: object, ampq_exception: Exception = None) -> None: super().__init__(*args) self.ampq_exception = ampq_exception + +class MidCallbackError(BaseException): + + def __init__(self, *args: object) -> None: + super().__init__(*args) diff --git a/nlds/rabbit/consumer.py b/nlds/rabbit/consumer.py index a2628e58..ef585ca5 100644 --- a/nlds/rabbit/consumer.py +++ b/nlds/rabbit/consumer.py @@ -36,7 +36,7 @@ RABBIT_CONFIG_QUEUES, LOGGING_CONFIG_STDOUT_LEVEL, RABBIT_CONFIG_QUEUE_NAME, LOGGING_CONFIG_ROLLOVER) from ..details import PathDetails, Retries -from ..errors import RabbitRetryError +from ..errors import RabbitRetryError, CallbackError logger = logging.getLogger("nlds.root") @@ -518,6 +518,7 @@ def _wrapped_callback(self, ch: Channel, method: Method, properties: Header, KeyError, PermissionError, RabbitRetryError, + CallbackError, JSONDecodeError, HTTPError, ) as original_error: From 452dedccad2c89b138fb2e33d8513b9b30224eb3 Mon Sep 17 00:00:00 2001 From: Jack Leland Date: Fri, 10 Feb 2023 17:32:52 +0000 Subject: [PATCH 15/41] Fix perpetual routing on non-existent holding request --- nlds_processors/catalog/catalog_worker.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/nlds_processors/catalog/catalog_worker.py b/nlds_processors/catalog/catalog_worker.py index 29ac138c..afe3a242 100644 --- a/nlds_processors/catalog/catalog_worker.py +++ b/nlds_processors/catalog/catalog_worker.py @@ -35,6 +35,7 @@ from nlds.rabbit.consumer import RabbitMQConsumer as RMQC from nlds.rabbit.consumer import State +from nlds.errors import CallbackError from nlds_processors.catalog.catalog import Catalog, CatalogError from nlds_processors.catalog.catalog_models import Storage @@ -331,7 +332,10 @@ def _catalog_get(self, body: dict, rk_origin: str) -> None: ) except CatalogError as e: self.log(e.message, RMQC.RK_LOG_ERROR) - return + message = (f"Could not find record of requested holding: " + f"label: {holding_label}, id: {holding_id}") + self.log(message, self.RK_LOG_DEBUG) + raise CallbackError(message) for f in filelist: file_details = PathDetails.from_dict(f) From d914f54db0c1f4419a36d1da22e7c21ff39e1d43 Mon Sep 17 00:00:00 2001 From: Jack Leland Date: Fri, 10 Feb 2023 17:34:53 +0000 Subject: [PATCH 16/41] Fix typo in callback error --- nlds/errors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nlds/errors.py b/nlds/errors.py index e99c8bf0..7ac6b9e5 100644 --- a/nlds/errors.py +++ b/nlds/errors.py @@ -23,7 +23,7 @@ def __init__(self, *args: object, ampq_exception: Exception = None) -> None: super().__init__(*args) self.ampq_exception = ampq_exception -class MidCallbackError(BaseException): +class CallbackError(BaseException): def __init__(self, *args: object) -> None: super().__init__(*args) From e4bc3f95a6df20ce2f1a1bc49127fd875d5479ca Mon Sep 17 00:00:00 2001 From: Neil Massey Date: Mon, 13 Feb 2023 15:23:30 +0000 Subject: [PATCH 17/41] Added warning to monitor db --- docs/spec/uml/monitor_db.png | Bin 32243 -> 42674 bytes docs/spec/uml/monitor_db.puml | 7 +++++++ 2 files changed, 7 insertions(+) diff --git a/docs/spec/uml/monitor_db.png b/docs/spec/uml/monitor_db.png index b47336a98c0e538741716149c8e7087cd8dfc1d0..8fef0ec4a9df57cb078504ff8a5f09a7534d16ab 100644 GIT binary patch literal 42674 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

!zB1N5N!6uKa79xk0NJa@%3_-F)n6 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

0+fVcfEO!O9%0@uD;vABm0K`zvSK7| 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 diff --git a/docs/spec/uml/monitor_db.puml b/docs/spec/uml/monitor_db.puml index 7272ecf5..71db5461 100644 --- a/docs/spec/uml/monitor_db.puml +++ b/docs/spec/uml/monitor_db.puml @@ -10,6 +10,12 @@ object "**Transaction Record**" as tran_rec { creation_time [DATETIME] } + +object "**Warning**" as warn { + id [INT](unique) + warning [STRING] +} + object "**Sub Record**" as sub_rec { id [INT](unique) sub_id [UUID_64|STRING](unique) @@ -26,5 +32,6 @@ object "**Failed Files**" as fail_rec { tran_rec "1" *-- "many" sub_rec sub_rec "0" *-- "many" fail_rec +tran_rec "0" *-- "many" warn @enduml From dbec9aa393366fb537663b70e4e57ac7c133f184 Mon Sep 17 00:00:00 2001 From: Neil Massey Date: Mon, 13 Feb 2023 15:24:49 +0000 Subject: [PATCH 18/41] Added warning to messages --- nlds/rabbit/consumer.py | 5 +++++ nlds/rabbit/publisher.py | 2 ++ 2 files changed, 7 insertions(+) diff --git a/nlds/rabbit/consumer.py b/nlds/rabbit/consumer.py index 376af9c2..5f8fa530 100644 --- a/nlds/rabbit/consumer.py +++ b/nlds/rabbit/consumer.py @@ -235,6 +235,7 @@ def parse_filelist(self, body_json: dict) -> List[PathDetails]: def send_pathlist(self, pathlist: List[PathDetails], routing_key: str, body_json: Dict[str, str], state: State = None, mode: FilelistType = FilelistType.processed, + warning: List[str] = None ) -> None: """Convenience function which sends the given list of PathDetails objects to the exchange with the given routing key and message body. @@ -295,6 +296,10 @@ def send_pathlist(self, pathlist: List[PathDetails], routing_key: str, self.publish_message(routing_key, body_json, delay=delay) # Send message to monitoring to keep track of state + # add any warning + if warning and len(warning) > 0: + body_json[self.MSG_DETAILS][self.MSG_WARNING] = warning + monitoring_rk = ".".join([routing_key[0], self.RK_MONITOR_PUT, self.RK_START]) diff --git a/nlds/rabbit/publisher.py b/nlds/rabbit/publisher.py index abd56c0c..3ce76b0e 100644 --- a/nlds/rabbit/publisher.py +++ b/nlds/rabbit/publisher.py @@ -113,6 +113,7 @@ class RabbitMQPublisher(): MSG_NEW_META = "new_meta" MSG_LABEL = "label" MSG_TAG = "tag" + MSG_DEL_TAG = "del_tag" MSG_PATH = "path" MSG_HOLDING_ID = "holding_id" MSG_HOLDING_LIST = "holdings" @@ -124,6 +125,7 @@ class RabbitMQPublisher(): MSG_GROUP_QUERY = "group_query" MSG_RETRY_COUNT = "retry_count" MSG_RECORD_LIST = "records" + MSG_WARNING = "warning" MSG_TYPE = "type" MSG_TYPE_STANDARD = "standard" From 710ea20e93f875b303c36d308f775fdad64e9cee Mon Sep 17 00:00:00 2001 From: Neil Massey Date: Mon, 13 Feb 2023 15:27:15 +0000 Subject: [PATCH 19/41] Removed unneeded imports --- nlds/routers/files.py | 1 - nlds/routers/find.py | 1 - 2 files changed, 2 deletions(-) diff --git a/nlds/routers/files.py b/nlds/routers/files.py index 592d28ca..6995a2fd 100644 --- a/nlds/routers/files.py +++ b/nlds/routers/files.py @@ -20,7 +20,6 @@ from ..routers import rabbit_publisher from ..rabbit.publisher import RabbitMQPublisher as RMQP -from . import rpc_publisher from ..errors import ResponseError from ..details import PathDetails from ..authenticators.authenticate_methods import authenticate_token, \ diff --git a/nlds/routers/find.py b/nlds/routers/find.py index dd62c73c..c17e0008 100644 --- a/nlds/routers/find.py +++ b/nlds/routers/find.py @@ -17,7 +17,6 @@ from typing import Optional, List, Dict from ..rabbit.publisher import RabbitMQPublisher as RMQP -from ..rabbit.rpc_publisher import RabbitMQRPCPublisher from ..routers import rpc_publisher from ..errors import ResponseError from ..authenticators.authenticate_methods import authenticate_token, \ From 3706653ff1c04077697cf05a20a42e58b3a03e22 Mon Sep 17 00:00:00 2001 From: Neil Massey Date: Mon, 13 Feb 2023 15:28:01 +0000 Subject: [PATCH 20/41] Tidied up tag processing --- nlds/routers/list.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/nlds/routers/list.py b/nlds/routers/list.py index 4ba8925a..4ab4760f 100644 --- a/nlds/routers/list.py +++ b/nlds/routers/list.py @@ -21,6 +21,7 @@ from ..authenticators.authenticate_methods import authenticate_token, \ authenticate_group, \ authenticate_user +from ..utils.process_tag import process_tag router = APIRouter() @@ -70,19 +71,11 @@ async def get(token: str = Depends(authenticate_token), meta_dict[RMQP.MSG_TRANSACT_ID] = transaction_id if (tag): - print(tag) tag_dict = {} # convert the string into a dictionary - # try: try: - # strip whitespace and "{" "}" symbolsfirst - tag_list = (tag.replace(" ","").replace("{", "").replace("}", "") - ).split(",") - for tag_i in tag_list: - if len(tag_i) > 0: - tag_kv = tag_i.split(":") - tag_dict[tag_kv[0]] = tag_kv[1] - except IndexError: # what exception might be raised here? + tag_dict = process_tag(tag) + except ValueError: response_error = ResponseError( loc = ["holdings", "get"], msg = "tag cannot be processed.", From 3a5b264c662704ac786f276d25618b6f4686b60c Mon Sep 17 00:00:00 2001 From: Neil Massey Date: Mon, 13 Feb 2023 15:29:10 +0000 Subject: [PATCH 21/41] Added deltag support --- nlds/routers/meta.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nlds/routers/meta.py b/nlds/routers/meta.py index 2097ceed..d1b82f06 100644 --- a/nlds/routers/meta.py +++ b/nlds/routers/meta.py @@ -30,6 +30,7 @@ class MetaModel(BaseModel): new_label: str = None new_tag: Dict[str, str] = None + del_tag: Dict[str, str] = None class MetaResponse(BaseModel): files: List[Dict] @@ -98,6 +99,8 @@ async def post(metamodel: MetaModel, new_meta_dict[RMQP.MSG_LABEL] = metamodel.new_label if (metamodel.new_tag): new_meta_dict[RMQP.MSG_TAG] = metamodel.new_tag + if (metamodel.del_tag): + new_meta_dict[RMQP.MSG_DEL_TAG] = metamodel.del_tag # add the "meta" section if (len(meta_dict) > 0): From c782ec7eb0e66234b65dd2c8112305587d44360f Mon Sep 17 00:00:00 2001 From: Neil Massey Date: Mon, 13 Feb 2023 15:29:28 +0000 Subject: [PATCH 22/41] Search on tags and del tag --- nlds_processors/catalog/catalog.py | 101 +++++++++++++++----- nlds_processors/catalog/catalog_worker.py | 107 ++++++++++++---------- 2 files changed, 137 insertions(+), 71 deletions(-) diff --git a/nlds_processors/catalog/catalog.py b/nlds_processors/catalog/catalog.py index f631f76d..fa6c5b8c 100644 --- a/nlds_processors/catalog/catalog.py +++ b/nlds_processors/catalog/catalog.py @@ -48,24 +48,52 @@ def get_holding(self, assert(self.session != None) try: if holding_id: - holding = self.session.query(Holding).filter( + holding_q = self.session.query(Holding).filter( Holding.user == user, Holding.group == group, Holding.id == holding_id, - ).all() + ) elif transaction_id: - holding = self.session.query(Holding).filter( + holding_q = self.session.query(Holding).filter( Holding.user == user, Holding.group == group, Transaction.holding_id == Holding.id, Transaction.transaction_id == transaction_id - ).all() + ) else: - holding = self.session.query(Holding).filter( + # label == None is return all holdings + if label is None: + search_label = ".*" + else: + search_label = label + + holding_q = self.session.query(Holding).filter( Holding.user == user, Holding.group == group, - Holding.label.regexp_match(label), - ).all() + Holding.label.regexp_match(search_label), + ) + # filter the query on any tags + if tag: + # get the holdings that have a key that matches one or more of + # the keys in the tag dictionary passed as a parameter + holding_q = holding_q.join(Tag).filter( + Tag.key.in_(tag.keys()) + ) + # check for zero + if holding_q.count == 0: + holding = [] + else: + # we have now got a subset of holdings with a tag that has + # a key that matches the keys in the input dictionary + # now find the holdings where the key and value match + for key, item in tag.items(): + holding_q = holding_q.filter( + Tag.key == key, + Tag.value == item + ) + holding = holding_q.all() + else: + holding = holding_q.all() # check if at least one holding found if len(holding) == 0: raise KeyError @@ -77,21 +105,21 @@ def get_holding(self, f"to access the holding with label:{h.label}." ) except (IntegrityError, KeyError, ArgumentError): + msg = "" if holding_id: - raise CatalogError( - f"Holding with holding_id:{holding_id} not found for " - f"user:{user} and group:{group}." - ) + msg = (f"Holding with holding_id:{holding_id} not found for " + f"user:{user} and group:{group}") elif transaction_id: - raise CatalogError( - f"Holding containing transaction_id:{transaction_id} not " - f"found for user:{user} and group:{group}." - ) + msg = (f"Holding containing transaction_id:{transaction_id} not " + f"found for user:{user} and group:{group}") else: - raise CatalogError( - f"Holding with label:{label} not found for " - f"user:{user} and group:{group}." - ) + msg = (f"Holding with label:{label} not found for " + f"user:{user} and group:{group}") + if tag: + msg += f" with tags:{tag}." + else: + msg += "." + raise CatalogError(msg) except (OperationalError): raise CatalogError( f"Invalid regular expression:{label} when listing holding for " @@ -125,7 +153,8 @@ def create_holding(self, def modify_holding(self, holding: Holding, new_label: str=None, - new_tags: dict=None) -> Holding: + new_tags: dict=None, + del_tags: dict=None) -> Holding: """Find a holding and modify the information in it""" assert(self.session != None) # change the label if a new_label supplied @@ -152,6 +181,12 @@ def modify_holding(self, else: # modify tag = self.modify_tag(holding, k, new_tags[k]) + if del_tags: + for k in del_tags: + # if the tag exists and the value matches then delete it + tag = self.get_tag(holding, k) + if tag.value == del_tags[k]: + self.del_tag(holding, k) self.session.flush() return holding @@ -303,13 +338,9 @@ def get_files(self, assert(self.session != None) # Nones are set to .* in the regexp matching # get the matching holdings first, these match all but the path - if holding_label: - holding_search = holding_label - else: - holding_search = ".*" holding = self.get_holding( - user, group, holding_search, holding_id, transaction_id, tag + user, group, holding_label, holding_id, transaction_id, tag ) if path: @@ -515,3 +546,23 @@ def modify_tag(self, holding: Holding, key: str, value: str): f"Tag with key:{key} not found" ) return tag + + + def del_tag(self, holding: Holding, key: str): + """Delete a tag that has the key""" + assert(self.session != None) + # use a checkpoint as the tags are being deleted in an external loop and + # using a checkpoint will ensure that any completed deletes are committed + checkpoint = self.session.begin_nested() + try: + tag = self.session.query(Tag).filter( + Tag.key == key, + Tag.holding_id == holding.id + ).one() # uniqueness constraint guarantees only one + self.session.delete(tag) + except (NoResultFound, KeyError): + checkpoint.rollback() + raise CatalogError( + f"Tag with key:{key} not found" + ) + return None diff --git a/nlds_processors/catalog/catalog_worker.py b/nlds_processors/catalog/catalog_worker.py index 0753bd0c..fd6d8088 100644 --- a/nlds_processors/catalog/catalog_worker.py +++ b/nlds_processors/catalog/catalog_worker.py @@ -247,11 +247,20 @@ def _catalog_put(self, body: dict, rk_origin: str) -> None: # add the tags - if the tag already exists then don't add it or modify # it, with the reasoning that the user can change it with the `meta` # command. - for k in tags: - try: - tag = self.catalog.get_tag(holding, k) - except CatalogError: # tag's key not found so create - self.catalog.create_tag(holding, k, tags[k]) + warnings = [] + if tags: + for k in tags: + try: + tag = self.catalog.get_tag(holding, k) + except CatalogError: # tag's key not found so create + self.catalog.create_tag(holding, k, tags[k]) + else: + # append a warning that the tag could not be added to the holding + warnings.append( + f"Tag with key:{k} could not be added to holding with label" + f":{label} as that tag already exists. Tags can be modified" + f" using the meta command" + ) # stop db transitions and commit self.catalog.save() @@ -268,7 +277,8 @@ def _catalog_put(self, body: dict, rk_origin: str) -> None: self.RK_LOG_DEBUG ) self.send_pathlist(self.completelist, rk_complete, body, - state=State.CATALOG_PUTTING) + state=State.CATALOG_PUTTING, + warning=warnings) # RETRY if len(self.retrylist) > 0: rk_retry = ".".join([rk_origin, @@ -279,7 +289,8 @@ def _catalog_put(self, body: dict, rk_origin: str) -> None: self.RK_LOG_DEBUG ) self.send_pathlist(self.retrylist, rk_retry, body, mode="retry", - state=State.CATALOG_PUTTING) + state=State.CATALOG_PUTTING, + warning=warnings) # FAILED if len(self.failedlist) > 0: rk_failed = ".".join([rk_origin, @@ -290,7 +301,8 @@ def _catalog_put(self, body: dict, rk_origin: str) -> None: self.RK_LOG_DEBUG ) self.send_pathlist(self.failedlist, rk_failed, body, - mode="failed") + mode="failed", + warning=warnings) def _catalog_get(self, body: dict, rk_origin: str) -> None: @@ -567,6 +579,7 @@ def _catalog_del(self, body: dict, rk_origin: str) -> None: self.catalog.save() self.catalog.end_session() + def _catalog_list(self, body: dict, properties: Header) -> None: """List the users holdings""" # get the user id from the details section of the message @@ -600,7 +613,7 @@ def _catalog_list(self, body: dict, properties: Header) -> None: try: holding_label = body[self.MSG_META][self.MSG_LABEL] except KeyError: - holding_label = ".*" + holding_label = None # get the tags from the details sections of the message try: @@ -653,6 +666,7 @@ def _catalog_list(self, body: dict, properties: Header) -> None: correlation_id=properties.correlation_id ) + def _catalog_stat(self, body: dict, properties: Header) -> None: """Get the labels for a list of transaction ids""" # get the user id from the details section of the message @@ -731,6 +745,7 @@ def _catalog_stat(self, body: dict, properties: Header) -> None: correlation_id=properties.correlation_id ) + def _catalog_find(self, body: dict, properties: Header) -> None: """List the user's files""" # get the user id from the details section of the message @@ -908,39 +923,49 @@ def _catalog_meta(self, body: dict, properties: Header) -> None: except KeyError: new_tag = None + # get the deleted tag(s) from the new_meta section of the message + try: + del_tag = body[self.MSG_META][self.MSG_NEW_META][self.MSG_DEL_TAG] + except KeyError: + del_tag = None + self.catalog.start_session() # if there is the holding label or holding id then get the holding try: - if not holding_label and not holding_id: + if not holding_label and not holding_id and not tag: raise CatalogError( - "Holding not found: holding_id or label not specified" + "Holding not found: holding_id or label or tag(s) not specified." ) holdings = self.catalog.get_holding( - user, group, holding_label, holding_id, tag + user, group, holding_label, holding_id, tag=tag ) + ret_list = [] + for holding in holdings: + # get the old metadata so we can record it, then modify + old_meta = { + "label": holding.label, + "tags" : holding.get_tags() + } + holding = self.catalog.modify_holding( + holding, new_label, new_tag, del_tag + ) + # record the new metadata + new_meta = { + "label": holding.label, + "tags": holding.get_tags() + } + # build the return dictionary and append it to the list of + # holdings that have been modified + ret_dict = { + "id": holding.id, + "user": holding.user, + "group": holding.group, + "old_meta" : old_meta, + "new_meta" : new_meta, + } + ret_list.append(ret_dict) - if len(holdings) > 1: - if holding_label: - raise CatalogError( - f"More than one holding returned for label:" - f"{holding_label}" - ) - elif holding_id: - raise CatalogError( - f"More than one holding returned for holding_id:" - f"{holding_id}" - ) - else: - holding = holdings[0] - - old_meta = { - "label": holding.label, - "tags": holding.get_tags(), - } - holding = self.catalog.modify_holding( - holding, new_label, new_tag - ) self.catalog.save() except CatalogError as e: # failed to get the holdings - send a return message saying so @@ -948,20 +973,10 @@ def _catalog_meta(self, body: dict, properties: Header) -> None: body[self.MSG_DETAILS][self.MSG_FAILURE] = e.message body[self.MSG_DATA][self.MSG_HOLDING_LIST] = [] else: - # fill the return message with a dictionary of the holding - ret_dict = { - "id": holding.id, - "user": holding.user, - "group": holding.group, - "old_meta" : old_meta, - "new_meta" : { - "label": holding.label, - "tags": holding.get_tags() - } - } - body[self.MSG_DATA][self.MSG_HOLDING_LIST] = [ret_dict] + # fill the return message with a dictionary of the holding(s) + body[self.MSG_DATA][self.MSG_HOLDING_LIST] = ret_list self.log( - f"Modified metadata from CATALOG_META {ret_dict}", + f"Modified metadata from CATALOG_META {ret_list}", self.RK_LOG_DEBUG ) From 6228d53d84ea46cef13de141d882f8818f1ac1dc Mon Sep 17 00:00:00 2001 From: Neil Massey Date: Mon, 13 Feb 2023 15:30:17 +0000 Subject: [PATCH 23/41] Added warning for monitor --- nlds_processors/monitor/monitor.py | 24 +++++++++++++++++++++-- nlds_processors/monitor/monitor_models.py | 16 +++++++++++++++ nlds_processors/monitor/monitor_worker.py | 19 ++++++++++++++++-- 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/nlds_processors/monitor/monitor.py b/nlds_processors/monitor/monitor.py index 17fe4651..6411d9bd 100644 --- a/nlds_processors/monitor/monitor.py +++ b/nlds_processors/monitor/monitor.py @@ -3,7 +3,7 @@ from sqlalchemy.orm import Session from nlds_processors.monitor.monitor_models import MonitorBase, TransactionRecord -from nlds_processors.monitor.monitor_models import SubRecord, FailedFile +from nlds_processors.monitor.monitor_models import SubRecord, FailedFile, Warning from nlds.rabbit.consumer import State from nlds.details import PathDetails @@ -271,4 +271,24 @@ def check_completion(self, except IntegrityError: raise MonitorError( "IntegrityError raised when attempting to get sub_records" - ) \ No newline at end of file + ) + + + def create_warning(self, + transaction_record: TransactionRecord, + warning: str) -> Warning: + """Create a warning and add it to the TransactionRecord""" + assert(self.session != None) + try: + warning = Warning( + warning = warning, + transaction_record_id = transaction_record.id + ) + self.session.add(warning) + self.session.flush() + except (IntegrityError, KeyError): + raise MonitorError( + f"Warning for transaction_record:{transaction_record.id} could " + "not be added to the database" + ) + return warning \ No newline at end of file diff --git a/nlds_processors/monitor/monitor_models.py b/nlds_processors/monitor/monitor_models.py index 9f73b9a5..bca9b4b0 100644 --- a/nlds_processors/monitor/monitor_models.py +++ b/nlds_processors/monitor/monitor_models.py @@ -28,6 +28,13 @@ class TransactionRecord(MonitorBase): creation_time = Column(DateTime, default=func.now()) # relationship for SubRecords (One to many) sub_records = relationship("SubRecord") + # relationship for Warnings (One to many) + warnings = relationship("Warning", cascade="delete, delete-orphan") + def get_warnings(self): + warnings = [] + for w in self.warnings: + warnings.append(w.warning) + return warnings class SubRecord(MonitorBase): @@ -76,6 +83,15 @@ class FailedFile(MonitorBase): sub_record_id = Column(Integer, ForeignKey("sub_record.id"), index=True, nullable=False) +class Warning(MonitorBase): + __tablename__ = "warning" + + # just two columns - primary key and warning string + id = Column(Integer, primary_key=True) + warning = Column(String) + # linnk to transaction record warning about + transaction_record_id = Column(Integer, ForeignKey("transaction_record.id"), + index=True, nullable=False) def orm_to_dict(obj): retdict = obj.__dict__ diff --git a/nlds_processors/monitor/monitor_worker.py b/nlds_processors/monitor/monitor_worker.py index 7b9aa6c5..ef001f0d 100644 --- a/nlds_processors/monitor/monitor_worker.py +++ b/nlds_processors/monitor/monitor_worker.py @@ -147,13 +147,22 @@ def _monitor_put(self, body: Dict[str, str]) -> None: self.log("No retry_fl found in message, assuming false.", self.RK_LOG_DEBUG) + # get the warning(s) from the details section of the message + try: + warnings = body[self.MSG_DETAILS][self.MSG_WARNING] + except KeyError: + self.log("No warning found in message, continuing without", + self.RK_LOG_DEBUG) + warnings = [] + print(warnings) + # start the database transactions self.monitor.start_session() # For any given monitoring update, we need to: # - find the transaction record (create if not present) # - update the subrecord(s) associated with it - # - find an exisiting + # - find an existing # - see if it matches sub_id in message # - update it if it does # - change state @@ -176,12 +185,17 @@ def _monitor_put(self, body: Dict[str, str]) -> None: try: trec = self.monitor.create_transaction_record( user, group, transaction_id, job_label, api_action - ) + ) except MonitorError as e: self.log(e.message, RMQC.RK_LOG_ERROR) else: trec = trec[0] + # create any warnings if there are any + if warnings and len(warnings) > 0: + for w in warnings: + warning = self.monitor.create_warning(trec, w) + try: srec = self.monitor.get_sub_record(sub_id) except MonitorError: @@ -383,6 +397,7 @@ def _monitor_get(self, body: Dict[str, str], properties: Header) -> None: "job_label": tr.job_label, "api_action": tr.api_action, "creation_time": tr.creation_time.isoformat(), + "warnings": tr.get_warnings(), "sub_records" : [] } trecs_dict[tr.id] = t_rec From a407094363b4181a12a0c1005c30c642cda2d3ee Mon Sep 17 00:00:00 2001 From: Neil Massey Date: Mon, 13 Feb 2023 15:30:47 +0000 Subject: [PATCH 24/41] Added whitespace --- nlds_processors/nlds_worker.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/nlds_processors/nlds_worker.py b/nlds_processors/nlds_worker.py index c7db4805..c0a7e9df 100644 --- a/nlds_processors/nlds_worker.py +++ b/nlds_processors/nlds_worker.py @@ -112,7 +112,8 @@ def _process_rk_list(self, body_json: dict) -> None: self.log(f"Sending message to {queue} queue with routing " f"key {new_routing_key}", self.RK_LOG_INFO) self.publish_and_log_message(new_routing_key, body_json) - + + def _process_rk_index_complete(self, body_json: dict) -> None: # forward to catalog-put on the catalog_q self.log(f"Index successful, sending file list for cataloguing.", @@ -125,6 +126,7 @@ def _process_rk_index_complete(self, body_json: dict) -> None: f"key {new_routing_key}", self.RK_LOG_INFO) self.publish_and_log_message(new_routing_key, body_json) + def _process_rk_catalog_put_complete(self, body_json: dict) -> None: self.log(f"Catalog successful, sending filelist for transfer", self.RK_LOG_INFO) @@ -137,12 +139,14 @@ def _process_rk_catalog_put_complete(self, body_json: dict) -> None: f"key {new_routing_key}", self.RK_LOG_INFO) self.publish_and_log_message(new_routing_key, body_json) + def _process_rk_transfer_put_complete(self, body_json: dict) -> None: # Nothing happens after a successful transfer anymore, so we leave this # empty in case any future messages are required (queueing archive for # example) pass + def _process_rk_transfer_put_failed(self, body_json: dict) -> None: self.log(f"Transfer unsuccessful, sending failed files back to catalog " "for deletion", @@ -217,6 +221,7 @@ def callback(self, ch: Channel, method: Method, properties: Header, self.log(f"Worker callback complete!", self.RK_LOG_INFO) + def publish_and_log_message(self, routing_key: str, msg: dict, log_fl=True) -> None: """ From 282576c3772656eeb65b0ba9b32b726765e243af Mon Sep 17 00:00:00 2001 From: Neil Massey Date: Mon, 13 Feb 2023 15:31:18 +0000 Subject: [PATCH 25/41] Put the logger back in --- test_run/test_run.rc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_run/test_run.rc b/test_run/test_run.rc index 4eb843d5..fde73f78 100644 --- a/test_run/test_run.rc +++ b/test_run/test_run.rc @@ -58,5 +58,5 @@ focus # left pos 4 screen -t "logger" -#exec "$PYTHON_DIR/python" "$NLDS_PROC/logger.py" +exec "$PYTHON_DIR/python" "$NLDS_PROC/logger.py" From f3485f68266059150c05abdbbfeeeb376a546a84 Mon Sep 17 00:00:00 2001 From: Jack Leland Date: Tue, 14 Feb 2023 10:20:43 +0000 Subject: [PATCH 26/41] Update to docs with instructions for setting up a cta emulator --- docs/source/cta_emulator.rst | 187 ++++++++++++++++++++++++++++++++ docs/source/index.rst | 10 +- docs/source/nlds-processors.rst | 35 +++++- docs/source/nlds-server.rst | 8 +- 4 files changed, 229 insertions(+), 11 deletions(-) create mode 100644 docs/source/cta_emulator.rst diff --git a/docs/source/cta_emulator.rst b/docs/source/cta_emulator.rst new file mode 100644 index 00000000..0b855bff --- /dev/null +++ b/docs/source/cta_emulator.rst @@ -0,0 +1,187 @@ +CERN Tape Archive Set Up +======================== + +As part of the development of the NLDS, a tape emulator was set up to better +understand how to interact with ``xrootd`` and the CERN tape archive. The +following instructions detail how to set up such a tape emulator and are adapted +from the instructions on [the CTA repo] +(https://gitlab.cern.ch/cta/CTA/-/tree/main/continuousintegration/buildtree_runner) +and those provided by STFC's Scientific Computing Department at the Rutherford +Appleton Laboratory. There are two major parts: commissioning the virtual +machine, and setting it up appropriately according to the CERN instructions. + + +Commissioning the VM +-------------------- + +These instructions are specifically for commissioning a VM on the STFC openstack +cloud interface. For other machines, slightly different instructions will need +to be followed. + +After you have logged in to the openstack interface, you click on the "Launch +Instance" button on the top to create the VM and then: + +1. In the "Details" tab, give your VM a suitable name +2. In the "Source" tab, select scientificlinux-7-aq as a source image +3. In the "Flavour" tab, select a VM type depending on how many VCPUs, RAM and + disk size you need +4. In the "Networks" tab, select "Internal" +5. In the "Key Pair" tab, upload your public rsa ssh key so you can login to the + VM once it is created. +6. In the "Metadata" tab, click on the pull-down menu "Aquilon Image Properties" + and then set it to "Aquilon Archetype", specifying ``ral-tier1``, and also + "Aquilon Personality" specifying it as ``eoscta_ci_cd``. Note that you will + have to manually write these values out so it's worth copy pasting to avoid + typos! +7. Press the "Launch Instance" button and the VM will be created. Give it some + time so that quattor runs - quattor being a vm management tool like Puppet. + It may also need a reboot at some point. + +This setup produces a vm which requires logging in as your openstack username - +in most cases this will be your STFC federal ID. You will be able to sudo +assuming the machine remains in the correct configuration. + +**Note:** +The above setup is one that theoretically works. However, the machine which – +after some attempts – successfully had CTA installed on it had to be +commissioned manually by SCD so that + +(a) quattor could be made sure to have run successfully and then subsequently + disabled +(b) I would be able to log in as ``root`` thus negating the need to edit the + sudoers file after each reboot. + +I would strongly recommend this approach if SCD are agreeable to commissioning +your vm for you. + + +Setting up CTA on the VM +------------------------ + +The following are the working set of instructions at time of writing, provided +by SCD at RAL. + +* **Ensure quattor is not running** + + There should be an empty file at ``/etc/noquattor``, if there is not one then + create it with + + ``sudo touch /etc/noquattor`` + +* **Clone the CTA gitlab repo** + + ``git clone https://gitlab.cern.ch/cta/CTA.git`` + +* **User environment** + + As per instructions + + ``cd ./CTA/continuousintegration/buildtree_runner/vmBootstrap`` + + BUT in bootstrapSystem.sh, delete/comment line 46 and then + + ``./bootstrapSystem.sh`` + + When prompted for password, press return (i.e don't give one). This creates + the ``cta`` user and adds them to sudoers + + +* **CTA build tree** + + As per instructions + + ``su - cta`` + ``cd ~/CTA/continuousintegration/buildtree_runner/vmBootstrap`` + + BUT edit lines 54,55 in bootstrapCTA.sh to look like + + ``sudo wget https://public-yum.oracle.com/RPM-GPG-KEY-oracle-ol7 -O /etc/pki/rpm-gpg/RPM-GPG-KEY-oracle --no-check-certificate`` + ``sudo wget https://download.ceph.com/keys/release.asc -O /etc/pki/rpm-gpg/RPM-ASC-KEY-ceph --no-check-certificate`` + + Note the change in the URL on line 55 from ``git.ceph.com`` to + ``download.ceph.com``, as well as the addition of the ``--no-check-certificate`` + flag. + + Then run bootstrapCTA.sh (without any args) + + ``./bootstrapCTA.sh`` + + +* **Install MHVTL** + + As per instructions + + ``cd ~/CTA/continuousintegration/buildtree_runner/vmBootstrap`` + ``./bootstrapMHVTL.sh`` + +* **Kubernetes setup** + + As per instructions + + ``cd ~/CTA/continuousintegration/buildtree_runner/vmBootstrap`` + ``./bootstrapKubernetes.sh`` + + and reboot host + + ``sudo reboot`` + +* **Docker image** + + Depending on how your machine was set up you may now need to ensure that + quattor is still disabled (i.e. that the ``/etc/noquattor`` file still exists) + and that the cta user is still in the sudoers file. This will not be necessary + if you are running as ``root``. + + Then, as per instructions + + ``su - cta`` + ``cd ~/CTA/continuousintegration/buildtree_runner`` + + BUT edit lines 38,39 in /home/cta/CTA/continuousintegration/docker/ctafrontend/cc7/buildtree-stage1-rpms-public/Dockerfile to look like + + ``RUN wget https://public-yum.oracle.com/RPM-GPG-KEY-oracle-ol7 -O /etc/pki/rpm-gpg/RPM-GPG-KEY-oracle --no-check-certificate`` + ``RUN wget https://download.ceph.com/keys/release.asc -O /etc/pki/rpm-gpg/RPM-ASC-KEY-ceph --no-check-certificate`` + + then run the master script to prepare all the Docker images. + + ``./prepareImage.sh`` + +* **Preparing the environment (MHVTL, kubernetes volumes...)** + + As per instructions + + ``cd ~/CTA/continuousintegration/buildtree_runner`` + ``sudo ./recreate_buildtree_running_environment.sh`` + +* **Preparing the CTA instance** + + As per instructions + + ``cd ~/CTA/continuousintegration/orchestration`` + ``sudo ./create_instance.sh -n cta -b ~ -B CTA-build -O -D -d internal_postgres.yaml`` + + This may work first time but it never did for me, so the fix is to then run + + ``./delete_instance.sh -n cta`` + + To remove the instance and then re-create it with the same command as above + ``sudo ./create_instance.sh -n cta -b ~ -B CTA-build -O -D -d internal_postgres.yaml`` + + This can be verified to be working with a call to + + ``kubectl -n cta get pods`` + + which should return a list of the working pods, looking something like: + + ============ ======== ======== ========= === + NAME READY STATUS RESTARTS AGE + ============ ======== ======== ========= === + client 1/1 Running 0 35m + ctacli 1/1 Running 0 35m + ctaeos 1/1 Running 0 35m + ctafrontend 1/1 Running 0 35m + kdc 1/1 Running 0 35m + postgres 1/1 Running 0 36m + tpsrv01 2/2 Running 0 35m + tpsrv02 2/2 Running 0 35m + ============ ======== ======== ========= === diff --git a/docs/source/index.rst b/docs/source/index.rst index c65e4142..c2d5ac2f 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -8,14 +8,20 @@ Welcome to Near-line Data Store's documentation! .. toctree:: :maxdepth: 2 - :caption: Contents: + :caption: Contents - Getting started + Getting started Specification NLDS Server API NLDS Processors API +.. toctree:: + :maxdepth: 2 + :caption: Advanced + + Setting up a CTA tape emulator + Indices and tables ================== diff --git a/docs/source/nlds-processors.rst b/docs/source/nlds-processors.rst index a2a982ad..d8a861f3 100644 --- a/docs/source/nlds-processors.rst +++ b/docs/source/nlds-processors.rst @@ -1,5 +1,7 @@ -Core content of the nlds-processors -=================================== +Microservices +============= + +Core content of the nlds-processors. The Consumer class ------------------ @@ -10,7 +12,7 @@ The Consumer class The processors -------------- -Also referred to as 'microservices' +Also referred to as 'microservices', 'consumers', or 'workers': .. automodule:: nlds_processors.nlds_worker :members: @@ -18,13 +20,34 @@ Also referred to as 'microservices' .. automodule:: nlds_processors.index :members: -.. automodule:: nlds_processors.transfer +.. automodule:: nlds_processors.transferers.base_transfer + :members: + +.. automodule:: nlds_processors.transferers.put_transfer + :members: + +.. automodule:: nlds_processors.transferers.get_transfer + :members: + +.. automodule:: nlds_processors.db_mixin + :members: + +.. automodule:: nlds_processors.catalog.catalog_models + :members: + +.. automodule:: nlds_processors.catalog.catalog + :members: + +.. automodule:: nlds_processors.catalog.catalog_worker + :members: + +.. automodule:: nlds_processors.monitor.monitor_models :members: -.. automodule:: nlds_processors.catalog +.. automodule:: nlds_processors.monitor.monitor :members: -.. automodule:: nlds_processors.monitor +.. automodule:: nlds_processors.monitor.monitor_worker :members: .. automodule:: nlds_processors.logger diff --git a/docs/source/nlds-server.rst b/docs/source/nlds-server.rst index 6cd59ab4..b9d7851b 100644 --- a/docs/source/nlds-server.rst +++ b/docs/source/nlds-server.rst @@ -1,5 +1,7 @@ -Core content of the nlds-server -=============================== +NLDS API-server +=============== + +The core content of the NLDS API-server run using FastAPI. The Publisher class ------------------- @@ -28,7 +30,7 @@ The authenticators :members: :undoc-members: -Authenicate methods also contains 3 general methods, used by the above 2 +Authenticate methods also contains 3 general methods, used by the above 2 modules, to validate the given user, group and token. From bf176a2a3bdbfe4c28e0e82bba7452d33040c31a Mon Sep 17 00:00:00 2001 From: Jack Leland Date: Tue, 14 Feb 2023 11:39:19 +0000 Subject: [PATCH 27/41] Fix minor typo and leftover print statement --- nlds_processors/monitor/monitor_models.py | 2 +- nlds_processors/monitor/monitor_worker.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/nlds_processors/monitor/monitor_models.py b/nlds_processors/monitor/monitor_models.py index bca9b4b0..4965b01e 100644 --- a/nlds_processors/monitor/monitor_models.py +++ b/nlds_processors/monitor/monitor_models.py @@ -89,7 +89,7 @@ class Warning(MonitorBase): # just two columns - primary key and warning string id = Column(Integer, primary_key=True) warning = Column(String) - # linnk to transaction record warning about + # link to transaction record warning about transaction_record_id = Column(Integer, ForeignKey("transaction_record.id"), index=True, nullable=False) diff --git a/nlds_processors/monitor/monitor_worker.py b/nlds_processors/monitor/monitor_worker.py index dc8d681f..b42e6e93 100644 --- a/nlds_processors/monitor/monitor_worker.py +++ b/nlds_processors/monitor/monitor_worker.py @@ -163,7 +163,6 @@ def _monitor_put(self, body: Dict[str, str]) -> None: self.log("No warning found in message, continuing without", self.RK_LOG_DEBUG) warnings = [] - print(warnings) # start the database transactions self.monitor.start_session() From a5d6f5aac557d34df9b4afa66f9742c9e7febbf5 Mon Sep 17 00:00:00 2001 From: Jack Leland Date: Tue, 14 Feb 2023 16:27:55 +0000 Subject: [PATCH 28/41] Add in-progress doc page on server config --- .../{cta_emulator.rst => cta-emulator.rst} | 0 docs/source/index.rst | 3 +- docs/source/server-config.rst | 151 ++++++++++++++++++ 3 files changed, 153 insertions(+), 1 deletion(-) rename docs/source/{cta_emulator.rst => cta-emulator.rst} (100%) create mode 100644 docs/source/server-config.rst diff --git a/docs/source/cta_emulator.rst b/docs/source/cta-emulator.rst similarity index 100% rename from docs/source/cta_emulator.rst rename to docs/source/cta-emulator.rst diff --git a/docs/source/index.rst b/docs/source/index.rst index c2d5ac2f..34ed3b84 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -20,7 +20,8 @@ Welcome to Near-line Data Store's documentation! :maxdepth: 2 :caption: Advanced - Setting up a CTA tape emulator + The server config file + Setting up a CTA tape emulator Indices and tables diff --git a/docs/source/server-config.rst b/docs/source/server-config.rst new file mode 100644 index 00000000..64fe7390 --- /dev/null +++ b/docs/source/server-config.rst @@ -0,0 +1,151 @@ +Server config +============= + +The server config file controls the configurable behaviour of the NLDS. It is a +json file split into dictionary sections, with each section delineating +configuration for a specific part of the program. There is an example +server_config in the templates section of the main nlds package +(``nlds.templates.server_config``) to get you started, but this page will +demystify the configuration needed for (a) a local development copy of the nlds, +and (b) a production system spread across several pods/virtual machines. + +*Please note that the NLDS is in active development and all of this is subject +to change with no notice.* + +Required sections +----------------- + +There are two required sections for every server_config: ``authentication`` and +``rabbitMQ``. + +Authentication +^^^^^^^^^^^^^^ +This deals with how users are authenticated through the OAuth2 flow used in the +client. The following fields are required in the dictionary:: + + "authentication" : { + "authenticator_backend" : "jasmin_authenticator", + "jasmin_authenticator" : { + "user_profile_url" : "{{ user_profile_url }}", + "user_services_url" : "{{ user_services_url }}", + "oauth_token_introspect_url" : "{{ token_introspect_url }}" + } + } + +where ``authenticator_backend`` dictates which form of authentication you would +like to use. Currently the only implemented authenticator is the +``jasmin_authenticator``, but there are plans to expand this to also work with +other industry standard authenticators like google and microsoft. + +The authenticator setup is then specified in a separate dictionary named after +the authenticator, which is specific to each authenticator. The +``jasmin_authenticator`` requires, as above, values for ``user_profile_url``, +``user_services_url``, and ``oauth_token_introspect_url``. This cannot be +divulged publicly on github for JASMIN, so please get in contact for the actual +values to use. + +RabbitMQ +^^^^^^^^ + +This deals with how the nlds connects to the RabbitMQ queue and message +brokering system. The following is an outline of what is required:: + + "rabbitMQ": { + "user": "{{ rabbit_user }}", + "password": "{{ rabbit_password }}", + "server": "{{ rabbit_server }}", + "vhost": "{{ rabbit_vhost }}", + "exchange": { + "name": "{{ rabbit_exchange_name }}", + "type": "{{ rabbit_exchange_type }}", + "delayed": "{{ rabbit_exchange_delayed }}" + }, + "queues": [ + { + "name": "{{ rabbit_queue_name }}", + "bindings": [ + { + "exchange": "{{ rabbit_exchange_name }}", + "routing_key": "{{ rabbit_queue_routing_key }}" + } + ] + } + ] + } + +Here the ``user`` and ``password`` fields refer to the username and password for +the rabbit server you wish to connect to, which is in turn specified with +``server``. ``vhost`` is similarly the virtual host on the rabbit server that +you wish to connect to. + +The next two dictionaries are context specific. All publishing elements of the +NLDS, i.e. parts that will send messages, will require an exchange to publish +messages to. ``exchange`` is determines that exchange, with three required +subfields: ``name``, ``type``, and ``delayed``. The former two are self +descriptive, they should just be the name of the exchange on the virtualhost and +it's corresponding type e.g. one of fanout, direct or topic. ``delay`` is a +boolean (``true`` or ``false`` in json-speak) dictating whether to use the +delay functionality utilised within the NLDS. Note that this requires the rabbit +server have the DelayedRabbitExchange plugin installed. + +Exchanges can be declared and created if not present on the virtual host the +first time the NLDS is run, virtualhosts cannot and so will have to be created +beforehand manually on the server or through the admin interface. If an exchange +is requested but incorrect information given about either its `type` or +`delayed` status, then the NLDS will throw an error. + +``queues`` is a list of queue dictionaries and must be implemented on consumers, +i.e. message processors, to tell ``pika`` where to take messages from. Each +queue dictionary consists of a ``name`` and a list of `bindings`, with each +``binding`` being a dictionary containing the name of the ``exchange`` the queue +takes messages from, and the routing key that a message must have to be accepted +onto the queue. For more information on exchanges, routing keys, and other +RabbitMQ features, please see [Rabbit's excellent documentation] +(https://www.rabbitmq.com/tutorials/tutorial-five-python.html). + + +Generic optional sections +------------------------- + +There are 2 generic sections, i.e. those which are used across the NLDS +ecosystem, but are optional and therefore fall back on a default configuration +if not specified. These are ``logging``, and ``general``. + +Logging +^^^^^^^ + +General +^^^^^^^ + +Consumer-specific optional sections +----------------------------------- + +NLDS Worker +^^^^^^^^^^^ + +Indexer +^^^^^^^ + +Cataloguer +^^^^^^^^^^ + +Transfer-put +^^^^^^^^^^^^ + +Transfer-get +^^^^^^^^^^^^ + +Monitor +^^^^^^^ + +Logger +^^^^^^ + +Examples +======== + +Local NLDS +---------- + +Distributed NLDS +---------------- \ No newline at end of file From e00428833e91a9b4f117f2c4e49761e6d3ba2814 Mon Sep 17 00:00:00 2001 From: Jack Leland Date: Wed, 15 Feb 2023 15:31:48 +0000 Subject: [PATCH 29/41] Further updates to server config docs --- docs/source/conf.py | 8 +++- docs/source/server-config.rst | 75 +++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 8e2e240f..8cbae625 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -54,4 +54,10 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] \ No newline at end of file +html_static_path = ['_static'] + +html_logo = "ceda.png" +html_theme_options = { + 'logo_only': True, + 'display_version': False, +} \ No newline at end of file diff --git a/docs/source/server-config.rst b/docs/source/server-config.rst index 64fe7390..e1eebdc5 100644 --- a/docs/source/server-config.rst +++ b/docs/source/server-config.rst @@ -114,18 +114,93 @@ if not specified. These are ``logging``, and ``general``. Logging ^^^^^^^ +The logging configuration options look like the following:: + + "logging": { + "enable": boolean + "log_level": str - ("none" | "debug" | "info" | "warning" | "error" | "critical"), + "log_format": str - see python logging docs for details, + "add_stdout_fl": boolean, + "stdout_log_level": str - ("none" | "debug" | "info" | "warning" | "error" | "critical"), + "rollover": str - see python logging docs for details + } + +These all set default options the native python logging system, with +``log_level`` being the log level, ``log_format`` being a string describing the +log output format, and rollover describing the frequency of rollover for log +files in the standard manner. For details on all of this, see the python docs +for inbuilt logging. ``enable`` and ``add_stdout_fl`` are boolean flags +controlling log output to files and ``stdout`` respectively, and the +``stdout_log_level`` is the log level for the stdout logging, if you require it +to be different from the default log level. + +As stated, these all set the default log options for all publishers and +consumers within the NLDS - these can be overridden on a consumer-specific basis +by inserting a ``logging`` sub-dictionary into a consumer-specific optional +section. + General ^^^^^^^ +The general config, as of writing this page, only covers one option: the +retry_delays list:: + + "general": { + "retry_delays": List[int] + } + +This retry delays list gives the delay applied to retried messages in seconds, +with the `n`th element being the delay for the `n`th retry. Setting the value +here sets a default for _all_ consumers, but the retry_delays option can be +inserted into any consumer-specific config to override this. + Consumer-specific optional sections ----------------------------------- +Each of the consumers have their own configuration dictionary, named by +convention as ``{consumername}_q``, e.g. ``transfer_put_q``. Each has a set of +default options and will accept both a logging dictionary and a retry_delays +list for consumer-specific override of the default options, mentioned above. +Each consumer also has a specific set of config options, some shared, which will +control its behaviour. The following is a brief rundown of the server config +options for each consumer. + NLDS Worker ^^^^^^^^^^^ +The server config section is ``nlds_q``, and the following options are available:: + + "nlds_q":{ + "logging": [standard_logging_dictionary], + "retry_delays": List[int] + "print_tracebacks_fl": boolean, + } + +Not much specifically happens in the NLDS worker that requires configuration, so +it basically just has the default settings. One that has not been covered yet, +``print_tracebacks_fl``, is a boolean flag to control whether the full +stacktrace of any caught exception is sent to the logger. This is a standard +across all consumers. You may set retry_delays if you wish but the NLDS worker +doesn't retry messages specifically, only in the case of something going +unexpectedly wrong. Indexer ^^^^^^^ +Server config section is ``index_q``, and the following options are available:: + + "index_q":{ + "logging": {standard_logging_dictionary}, + "retry_delays": List[int] + "print_tracebacks_fl": boolean, + "filelist_max_length": int, + "message_threshold": int, + "max_retries": int, + "check_permissions_fl": boolean, + "check_filesize_fl": boolean, + } + +where ``logging``, ``retry_delays``, and ``print_tracebacks_fl`` are as above. + Cataloguer ^^^^^^^^^^ From dfd066a9a9e84ccc4f9821418126c61fb7689d80 Mon Sep 17 00:00:00 2001 From: Jack Leland Date: Wed, 15 Feb 2023 15:32:09 +0000 Subject: [PATCH 30/41] Add ceda logo to docs --- docs/source/ceda.png | Bin 0 -> 49266 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/source/ceda.png diff --git a/docs/source/ceda.png b/docs/source/ceda.png new file mode 100644 index 0000000000000000000000000000000000000000..5274a2522ccf869b7263231483e6dd54c81f7f16 GIT binary patch literal 49266 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 Date: Fri, 24 Feb 2023 16:20:27 +0000 Subject: [PATCH 31/41] Finish all sections for the server config docs --- docs/source/server-config.rst | 151 ++++++++++++++++++++++++++++++++-- 1 file changed, 143 insertions(+), 8 deletions(-) diff --git a/docs/source/server-config.rst b/docs/source/server-config.rst index e1eebdc5..1c73d6b4 100644 --- a/docs/source/server-config.rst +++ b/docs/source/server-config.rst @@ -122,6 +122,7 @@ The logging configuration options look like the following:: "log_format": str - see python logging docs for details, "add_stdout_fl": boolean, "stdout_log_level": str - ("none" | "debug" | "info" | "warning" | "error" | "critical"), + "log_files": List[str], "rollover": str - see python logging docs for details } @@ -134,6 +135,15 @@ controlling log output to files and ``stdout`` respectively, and the ``stdout_log_level`` is the log level for the stdout logging, if you require it to be different from the default log level. +``log_files`` is a list of strings describing the path or paths to log files +being written to. If no log files paths are given then no file logging will be +done. If active, the file logging will be done with a TimedRotatingFileHandler, +i.e. the files will be rotated on a rolling basis, with the rollover time +denoted by the ``rollover`` option, which is a time string similar to that found +in crontab. Please see the [python logging docs] +(https://docs.python.org/3/library/logging.handlers.html#logging.handlers.TimedRotatingFileHandler) +for more info on this. + As stated, these all set the default log options for all publishers and consumers within the NLDS - these can be overridden on a consumer-specific basis by inserting a ``logging`` sub-dictionary into a consumer-specific optional @@ -169,7 +179,7 @@ NLDS Worker ^^^^^^^^^^^ The server config section is ``nlds_q``, and the following options are available:: - "nlds_q":{ + "nlds_q": { "logging": [standard_logging_dictionary], "retry_delays": List[int] "print_tracebacks_fl": boolean, @@ -188,34 +198,159 @@ Indexer Server config section is ``index_q``, and the following options are available:: - "index_q":{ + "index_q": { "logging": {standard_logging_dictionary}, - "retry_delays": List[int] + "retry_delays": List[int], "print_tracebacks_fl": boolean, "filelist_max_length": int, "message_threshold": int, "max_retries": int, "check_permissions_fl": boolean, "check_filesize_fl": boolean, + "use_pwd_gid_fl": boolean } -where ``logging``, ``retry_delays``, and ``print_tracebacks_fl`` are as above. +where ``logging``, ``retry_delays``, and ``print_tracebacks_fl`` are, as above, +standard configurables within the NLDS consumer ecosystem. +``filelist_maxlength`` determines the maximum length that any file-list provided +to the indexer consumer during the `init` (i.e. `split`) step can be. Any +transaction that is given initially with a list that is longer than this value +will be split down into many sub-transactions with this as a maximum length. For +example, with the default value of 1000, and a transaction with an initial list +size of 2500, will be split into 3 sub-transactions; 2 of them having a +list of 1000 files and the remaining 500 files being put into the third +sub-transaction. + +``message threshold`` is very similar in that it places a limit on the total +size of files within a given filelist. It is applied at the indexing +(`nlds.index`) step when files have actually been statted, and so will further +sub-divide any sub-transactions at that point if they are too large or are +revealed to contain lots of folders with files in upon indexing. ``max_retries`` +control the maximum number of times an entry in a filelist can be attempted to +be indexed, either because it doesn't exist or the user doesn't have the +appropriate permissions to access it at time of indexing. This feeds into retry +delays, as each subsequent time a sub-transaction is retried it will be delayed +by the amount specified at that index within the ``retry_delays`` list. If +``max_retries`` exceeds ``len(retry_delays)``, then any retries which don't have +an explicit retry delay to use will use the final element in the ``retry_delays`` +list. + +``check_permissions_fl`` and ``check_filesize_fl`` are commonly used boolean +flags to control whether the indexer checks the permissions and filesize of +files respectively during the indexing step. + +``use_pwd_gid_fl`` is a final boolean flag which controls how permissions +checking goes about getting the gid to check group permissions against. If True, +it will _just_ use the gid found in the ``pwd`` table on whichever machine the +indexer is running on. If false, then this gid is used `as well as` all of those +found using the ``os.groups`` command - which will read all groups found on the +machine the indexer is running on. + Cataloguer ^^^^^^^^^^ -Transfer-put -^^^^^^^^^^^^ +The server config entry for the catalog consumer is as follows:: + + "catalog_q": { + "logging": {standard_logging_dictionary}, + "retry_delays": List[int], + "print_tracebacks_fl": boolean, + "db_engine": str, + "db_options": { + "db_name" : str, + "db_user" : str, + "db_passwd" : str, + "echo": boolean + }, + "max_retries": int + } + +where ``logging``, ``retry_delays``, and ``print_tracebacks_fl`` are, as above, +standard configurables within the NLDS consumer ecosystem. ``max_retries`` is +similarly available in the cataloguer, with the same meaning as above. + +Here we also have two keys which control database behaviour via SQLAlchemy: +``db_engine`` and ``db_options``. ``db_engine`` is a string which specifies +which SQL flavour you would like SQLAlchemy. Currently this has been tried with +SQLite and PostgreSQL but, given how SQLAlchemy works, we expect few roadblocks +interacting with other database types. ``db_options`` is a further +sub-dictionary specifying the database name (which must be appropriate for +your chosen flavour of database), along with the database username and password +(if in use), respectively controlled by the keys ``db_name``, ``db_user``, and +``db_password``. Finally in this sub-dictionary ``echo``, an optional +boolean flag which controls the auto-logging of the SQLAlchemy engine. + + +Transfer-put and Transfer-get +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The server entry for the transfer-put consumer is as follows:: + + "transfer_put_q": { + "logging": {standard_logging_dictionary}, + "max_retries": int, + "retry_delays": List[int], + "print_tracebacks_fl": boolean, + "filelist_max_length": int, + "check_permissions_fl": boolean, + "use_pwd_gid_fl": boolean, + "tenancy": "cedadev-o.s3.jc.rl.ac.uk", + "require_secure_fl": false + } + +where we have ``logging``, ``retry_delays`` and ``print_tracebacks_fl`` as their +standard definitions defined above, and ``max_retries``, ``filelist_max_length`` +, ``check_permissions_fl``, and ``use_pwd_gid_fl`` defined the same as for the +Indexer consumer. + +New definitions for the transfer processor are the ``tenancy`` and +``require_secure_fl``, which control ``minio`` behaviour. ``tenancy`` is a +string which denotes the address of the object store tenancy to upload/download +files to/from, and ``require_secure_fl`` which specifies whether or not you +require signed ssl certificates at the tenancy location. -Transfer-get -^^^^^^^^^^^^ Monitor ^^^^^^^ +The server config entry for the monitor consumer is as follows:: + + "monitor_q": { + "logging": {standard_logging_dictionary}, + "retry_delays": List[int], + "print_tracebacks_fl": boolean, + "db_engine": str, + "db_options": { + "db_name" : str, + "db_user" : str, + "db_passwd" : str, + "echo": boolean + } + } + +where ``logging``, ``retry_delays``, and ``print_tracebacks_fl`` have the +standard, previously stated definitions, and ``db_engine`` and ``db_options`` +are as defined for the Catalog consumer - due to the use of an SQL database on +the Monitor. Note the minimal retry control, as the monitor only retries +messages which failed due to an unexpected exception. + Logger ^^^^^^ +And finally, the server config entry for the Logger consumer is as follows:: + + "logging_q": { + "logging": {standard_logging_dictionary}, + "print_tracebacks_fl": boolean, + } + +where the options have been previously defined. Note that there is no special +configurable behaviour on the Logger consumer as it is simply a relay for +redirecting logging messages into log files. It should also be noted that the +``log_files`` option should be set in the logging sub-dictionary for this to +work properly, which may be a mandatory setting in future versions. + Examples ======== From f2e20261f84ae9371b8bf9eb25c9f956f7c9a519 Mon Sep 17 00:00:00 2001 From: Jack Leland Date: Fri, 24 Feb 2023 17:00:48 +0000 Subject: [PATCH 32/41] Reorganise server config docs and add example --- docs/source/index.rst | 3 +- docs/source/server-config/examples.rst | 224 ++++++++++++++++++ .../{ => server-config}/server-config.rst | 13 +- 3 files changed, 228 insertions(+), 12 deletions(-) create mode 100644 docs/source/server-config/examples.rst rename docs/source/{ => server-config}/server-config.rst (99%) diff --git a/docs/source/index.rst b/docs/source/index.rst index 34ed3b84..ec4f2af3 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -20,7 +20,8 @@ Welcome to Near-line Data Store's documentation! :maxdepth: 2 :caption: Advanced - The server config file + The server config file + Server config examples Setting up a CTA tape emulator diff --git a/docs/source/server-config/examples.rst b/docs/source/server-config/examples.rst new file mode 100644 index 00000000..62fc8130 --- /dev/null +++ b/docs/source/server-config/examples.rst @@ -0,0 +1,224 @@ + +Examples +======== + +Local NLDS +---------- + +What follows is an example server config file to use for a local, development +version of an NLDS system where all consumers are running concurrently on one +machine - likely a laptop or single vm. This file would be saved at +``/etc/server_config``:: + + { + "authentication" : { + "authenticator_backend" : "jasmin_authenticator", + "jasmin_authenticator" : { + "user_profile_url" : "[REDACTED]", + "user_services_url" : "[REDACTED]", + "oauth_token_introspect_url" : "[REDACTED]" + } + }, + "index_q":{ + "logging":{ + "enable": true + }, + "filelist_threshold": 10000, + "check_permissions_fl": true, + "use_pwd_gid_fl": true, + "retry_delays": [ + 0, + 1, + 2 + ] + }, + "nlds_q":{ + "logging":{ + "enable": true + } + }, + "transfer_put_q":{ + "logging":{ + "enable": true + }, + "tenancy": "example-tenancy.s3.uk", + "require_secure_fl": false, + "use_pwd_gid_fl": true, + "retry_delays": [ + 0, + 1, + 2 + ] + }, + "transfer_get_q":{ + "logging":{ + "enable": true + }, + "tenancy": "example-tenancy.s3.uk", + "require_secure_fl": false, + "use_pwd_gid_fl": true + }, + "monitor_q":{ + "db_engine": "sqlite", + "db_options": { + "db_name" : "//Users/jack.leland/nlds/nlds_monitor.db", + "db_user" : "", + "db_passwd" : "", + "echo": false + }, + "logging":{ + "enable": true + } + }, + "logging":{ + "log_level": "debug" + }, + "logging_q":{ + "logging":{ + "log_level": "debug", + "add_stdout_fl": false, + "stdout_log_level": "warning", + "log_files": [ + "logs/nlds_q.txt", + "logs/index_q.txt", + "logs/catalog_q.txt", + "logs/monitor_q.txt", + "logs/transfer_put_q.txt", + "logs/transfer_get_q.txt", + "logs/logging_q.txt", + "logs/api_server.txt" + ] + } + }, + "catalog_q":{ + "db_engine": "sqlite", + "db_options": { + "db_name" : "//Users/jack.leland/nlds/nlds_catalog.db", + "db_user" : "", + "db_passwd" : "", + "echo": false + }, + "retry_delays": [ + 0, + 1, + 2 + ], + "logging":{ + "enable": true + } + }, + "rabbitMQ": { + "user": "full_access", + "password": "passwordletmein123", + "server": "130.246.3.98", + "vhost": "delayed-test", + "exchange": { + "name": "test_exchange", + "type": "topic", + "delayed": true + }, + "queues": [ + { + "name": "nlds_q", + "bindings": [ + { + "exchange": "test_exchange", + "routing_key": "nlds-api.route.*" + }, + { + "exchange": "test_exchange", + "routing_key": "nlds-api.*.complete" + }, + { + "exchange": "test_exchange", + "routing_key": "nlds-api.*.failed" + } + ] + }, + { + "name": "monitor_q", + "bindings": [ + { + "exchange": "test_exchange", + "routing_key": "*.monitor-put.start" + }, + { + "exchange": "test_exchange", + "routing_key": "*.monitor-get.start" + } + ] + }, + { + "name": "index_q", + "bindings": [ + { + "exchange": "test_exchange", + "routing_key": "*.index.start" + }, + { + "exchange": "test_exchange", + "routing_key": "*.index.init" + } + ] + }, + { + "name": "catalog_q", + "bindings": [ + { + "exchange": "test_exchange", + "routing_key": "*.catalog-put.start" + }, + { + "exchange": "test_exchange", + "routing_key": "*.catalog-get.start" + }, + { + "exchange": "test_exchange", + "routing_key": "*.catalog-del.start" + } + ] + }, + { + "name": "transfer_put_q", + "bindings": [ + { + "exchange": "test_exchange", + "routing_key": "*.transfer-put.start" + } + ] + }, + { + "name": "transfer_get_q", + "bindings": [ + { + "exchange": "test_exchange", + "routing_key": "*.transfer-get.start" + } + ] + }, + { + "name": "logging_q", + "bindings": [ + { + "exchange": "test_exchange", + "routing_key": "*.log.*" + } + ] + } + ] + }, + "rpc_publisher": { + "queue_exclusivity_fl": true + } + } + +Note that this is purely an example and doesn't necessarily use all features +within the NLDS. For example, several individual consumers have ``retry_delays`` +set but not generic ``retry_delays`` is set in the ``general`` section. Note +also that the jasmin authenication configuration is redacted for security +purposes. + +Distributed NLDS +---------------- + +COMING SOON \ No newline at end of file diff --git a/docs/source/server-config.rst b/docs/source/server-config/server-config.rst similarity index 99% rename from docs/source/server-config.rst rename to docs/source/server-config/server-config.rst index 1c73d6b4..f04ff033 100644 --- a/docs/source/server-config.rst +++ b/docs/source/server-config/server-config.rst @@ -162,7 +162,7 @@ retry_delays list:: This retry delays list gives the delay applied to retried messages in seconds, with the `n`th element being the delay for the `n`th retry. Setting the value here sets a default for _all_ consumers, but the retry_delays option can be -inserted into any consumer-specific config to override this. +inserted into any consumer-specific config section to override this. Consumer-specific optional sections ----------------------------------- @@ -349,13 +349,4 @@ where the options have been previously defined. Note that there is no special configurable behaviour on the Logger consumer as it is simply a relay for redirecting logging messages into log files. It should also be noted that the ``log_files`` option should be set in the logging sub-dictionary for this to -work properly, which may be a mandatory setting in future versions. - -Examples -======== - -Local NLDS ----------- - -Distributed NLDS ----------------- \ No newline at end of file +work properly, which may be a mandatory setting in future versions. \ No newline at end of file From 98ae164fba5efe9c1a2ad9e7923944e5bbc34b78 Mon Sep 17 00:00:00 2001 From: Neil Massey Date: Wed, 1 Mar 2023 14:41:22 +0000 Subject: [PATCH 33/41] Added self.max_retries to constructor --- nlds/rabbit/consumer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nlds/rabbit/consumer.py b/nlds/rabbit/consumer.py index bab06076..4c24c3ff 100644 --- a/nlds/rabbit/consumer.py +++ b/nlds/rabbit/consumer.py @@ -155,6 +155,7 @@ def __init__(self, queue: str = None, setup_logging_fl=False): self.completelist = [] self.retrylist = [] self.failedlist = [] + self.max_retries = 5 # Controls default behaviour of logging when certain exceptions are # caught in the callback. From db2abd0c725ba736338b0da47b7411175cb0ccf4 Mon Sep 17 00:00:00 2001 From: Neil Massey Date: Wed, 1 Mar 2023 14:41:49 +0000 Subject: [PATCH 34/41] Made a nicer return information for GET, PUT --- nlds/routers/files.py | 62 +++++++++++++++++++++++++++++++++---------- 1 file changed, 48 insertions(+), 14 deletions(-) diff --git a/nlds/routers/files.py b/nlds/routers/files.py index bfc5ec17..42ee55ac 100644 --- a/nlds/routers/files.py +++ b/nlds/routers/files.py @@ -57,11 +57,20 @@ def get_cleaned_list(self) -> List[str]: class FileResponse(BaseModel): - uuid: UUID + transaction_id: UUID msg: str - + user: str = "" + group: str = "" + api_action: str = "" + job_label: str = "" + tenancy: str = "" + label: str = "" + holding_id: int = -1 + tag: str = "" ############################ GET METHOD ############################ +# this is not used by the NLDS client but is left in case another +# program contacts this URL @router.get("/", status_code = status.HTTP_202_ACCEPTED, responses = { @@ -99,14 +108,13 @@ async def get(transaction_id: UUID, job_label = transaction_id[0:8] # return response, job label accepted for processing response = FileResponse( - uuid = transaction_id, - msg = (f"GET transaction with transaction_id:{transaction_id} and " - f"job_label:{job_label} accepted for processing.") + transaction_id = transaction_id, + msg = (f"GET transaction accepted for processing.") ) contents = [filepath, ] # create the message dictionary - do this here now as it's more transparent routing_key = f"{RMQP.RK_ROOT}.{RMQP.RK_ROUTE}.{RMQP.RK_GETLIST}" - api_method = f"{RMQP.RK_GETLIST}" + api_action = f"{RMQP.RK_GETLIST}" msg_dict = { RMQP.MSG_DETAILS: { RMQP.MSG_TRANSACT_ID: str(transaction_id), @@ -117,7 +125,7 @@ async def get(transaction_id: UUID, RMQP.MSG_TARGET: target, RMQP.MSG_ACCESS_KEY: access_key, RMQP.MSG_SECRET_KEY: secret_key, - RMQP.MSG_API_ACTION: api_method, + RMQP.MSG_API_ACTION: api_action, RMQP.MSG_JOB_LABEL: job_label }, RMQP.MSG_DATA: { @@ -127,6 +135,14 @@ async def get(transaction_id: UUID, **Retries().to_dict(), RMQP.MSG_TYPE: RMQP.MSG_TYPE_STANDARD, } + response.user = user + response.group = group + response.api_action = api_action + if job_label: + response.job_label = job_label + if tenancy: + response.tenancy = tenancy + rabbit_publisher.publish_message(routing_key, msg_dict) return JSONResponse(status_code = status.HTTP_202_ACCEPTED, content = response.json()) @@ -170,9 +186,8 @@ async def put(transaction_id: UUID, job_label = transaction_id[0:8] # return response, transaction id accepted for processing response = FileResponse( - uuid = transaction_id, - msg = (f"GETLIST transaction with transaction_id:{transaction_id} and " - f"job_label:{job_label} accepted for processing.") + transaction_id = transaction_id, + msg = (f"GETLIST transaction accepted for processing.") ) # Convert filepath or filelist to lists @@ -201,14 +216,24 @@ async def put(transaction_id: UUID, **Retries().to_dict(), RMQP.MSG_TYPE: RMQP.MSG_TYPE_STANDARD, } + response.user = user + response.group = group + response.api_action = api_method + if job_label: + response.job_label = job_label + if tenancy: + response.tenancy = tenancy # add the metadata meta_dict = {} if (filemodel.label): meta_dict[RMQP.MSG_LABEL] = filemodel.label + response.label = filemodel.label if (filemodel.holding_id): meta_dict[RMQP.MSG_HOLDING_ID] = filemodel.holding_id + response.holding_id = filemodel.holding_id if (filemodel.tag): meta_dict[RMQP.MSG_TAG] = filemodel.tag + response.tag = filemodel.tag if (len(meta_dict) > 0): msg_dict[RMQP.MSG_META] = meta_dict @@ -258,12 +283,11 @@ async def put(transaction_id: UUID, if job_label is None: job_label = transaction_id[0:8] - + # return response, transaction id accepted for processing response = FileResponse( - uuid = transaction_id, - msg = (f"PUT transaction with transaction_id:{transaction_id} and " - f"job_label:{job_label} accepted for processing.\n") + transaction_id = transaction_id, + msg = (f"PUT transaction accepted for processing.") ) # create the message dictionary - do this here now as it's more transparent routing_key = f"{RMQP.RK_ROOT}.{RMQP.RK_ROUTE}.{RMQP.RK_PUT}" @@ -287,14 +311,24 @@ async def put(transaction_id: UUID, **Retries().to_dict(), RMQP.MSG_TYPE: RMQP.MSG_TYPE_STANDARD, } + response.user = user + response.group = group + response.api_action = api_method + if job_label: + response.job_label = job_label + if tenancy: + response.tenancy = tenancy # add the metadata meta_dict = {} if (filemodel.label): meta_dict[RMQP.MSG_LABEL] = filemodel.label + response.label = filemodel.label if (filemodel.holding_id): meta_dict[RMQP.MSG_HOLDING_ID] = filemodel.holding_id + response.holding_id = filemodel.holding_id if (filemodel.tag): meta_dict[RMQP.MSG_TAG] = filemodel.tag + response.tag = filemodel.tag if (len(meta_dict) > 0): msg_dict[RMQP.MSG_META] = meta_dict From 7799d27a233e10342a28214c620fcebdadda128e Mon Sep 17 00:00:00 2001 From: Neil Massey Date: Tue, 7 Mar 2023 15:58:26 +0000 Subject: [PATCH 35/41] Added SIGTERM handling --- nlds/rabbit/consumer.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/nlds/rabbit/consumer.py b/nlds/rabbit/consumer.py index 4c24c3ff..7824eeb0 100644 --- a/nlds/rabbit/consumer.py +++ b/nlds/rabbit/consumer.py @@ -20,6 +20,7 @@ import json from json.decoder import JSONDecodeError from urllib3.exceptions import HTTPError +import signal from pika.exceptions import StreamLostError, AMQPConnectionError from pika.channel import Channel @@ -93,6 +94,9 @@ def get_final_states(cls): def to_json(self): return self.value + +class SigTermError(Exception): + pass class RabbitMQConsumer(ABC, RabbitMQPublisher): DEFAULT_QUEUE_NAME = "test_q" @@ -108,6 +112,7 @@ class RabbitMQConsumer(ABC, RabbitMQPublisher): def __init__(self, queue: str = None, setup_logging_fl=False): super().__init__(name=queue, setup_logging_fl=False) + self.loop = True # TODO: (2021-12-21) Only one queue can be specified at the moment, # should be able to specify multiple queues to subscribe to but this @@ -580,6 +585,9 @@ def append_route_info(cls, body: Dict, route_info: str = None) -> Dict: else: body[cls.MSG_DETAILS][cls.MSG_ROUTE] = route_info return body + + def exit(self, *args): + raise SigTermError def run(self): """ @@ -592,7 +600,10 @@ def run(self): :return: """ - while True: + # set up SigTerm handler + signal.signal(signal.SIGTERM, self.exit) + + while self.loop: self.get_connection() try: @@ -601,7 +612,11 @@ def run(self): self.channel.start_consuming() except KeyboardInterrupt: - self.channel.stop_consuming() + self.loop = False + break + + except SigTermError: + self.loop = False break except (StreamLostError, AMQPConnectionError) as e: @@ -613,6 +628,8 @@ def run(self): # Catch all other exceptions and log them as critical. tb = traceback.format_exc() self.log(tb, self.RK_LOG_CRITICAL, exc_info=e) - - self.channel.stop_consuming() + self.loop = False break + + self.channel.stop_consuming() + From 40ba2dbfc5efff1a25e231ff8ca7015361637c88 Mon Sep 17 00:00:00 2001 From: Neil Massey Date: Tue, 7 Mar 2023 15:59:15 +0000 Subject: [PATCH 36/41] Better tag handling --- nlds/routers/files.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/nlds/routers/files.py b/nlds/routers/files.py index 42ee55ac..5a9b39cf 100644 --- a/nlds/routers/files.py +++ b/nlds/routers/files.py @@ -16,7 +16,6 @@ from uuid import UUID, uuid4 from typing import Optional, List, Dict from copy import deepcopy -import json from ..routers import rabbit_publisher from ..rabbit.publisher import RabbitMQPublisher as RMQP @@ -25,6 +24,7 @@ from ..authenticators.authenticate_methods import authenticate_token, \ authenticate_group, \ authenticate_user + router = APIRouter() # uuid (for testing) @@ -232,8 +232,9 @@ async def put(transaction_id: UUID, meta_dict[RMQP.MSG_HOLDING_ID] = filemodel.holding_id response.holding_id = filemodel.holding_id if (filemodel.tag): - meta_dict[RMQP.MSG_TAG] = filemodel.tag - response.tag = filemodel.tag + tag_dict = filemodel.tag + meta_dict[RMQP.MSG_TAG] = tag_dict + response.tag = tag_dict if (len(meta_dict) > 0): msg_dict[RMQP.MSG_META] = meta_dict @@ -327,8 +328,9 @@ async def put(transaction_id: UUID, meta_dict[RMQP.MSG_HOLDING_ID] = filemodel.holding_id response.holding_id = filemodel.holding_id if (filemodel.tag): - meta_dict[RMQP.MSG_TAG] = filemodel.tag - response.tag = filemodel.tag + tag_dict = filemodel.tag + meta_dict[RMQP.MSG_TAG] = tag_dict + response.tag = tag_dict if (len(meta_dict) > 0): msg_dict[RMQP.MSG_META] = meta_dict From bc611a647301acd560a61e5836c9d29bc6bf4175 Mon Sep 17 00:00:00 2001 From: Neil Massey Date: Tue, 7 Mar 2023 15:59:39 +0000 Subject: [PATCH 37/41] Removing stripping whitespace from tags --- nlds/utils/process_tag.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/nlds/utils/process_tag.py b/nlds/utils/process_tag.py index 643d3f32..410a1bff 100644 --- a/nlds/utils/process_tag.py +++ b/nlds/utils/process_tag.py @@ -1,15 +1,16 @@ def process_tag(tag): """Process a tag in string format into dictionary format""" - try: + # try: + if True: tag_dict = {} - # strip whitespace and "{" "}" symbolsfirst - tag_list = (tag.replace(" ","").replace("{", "").replace("}", "") + # strip "{" "}" symbolsfirst + tag_list = (tag.replace("{", "").replace("}", "") ).split(",") for tag_i in tag_list: tag_kv = tag_i.split(":") if len(tag_kv) < 2: continue tag_dict[tag_kv[0]] = tag_kv[1] - except: # what exceptions might be raised here? - raise ValueError + # except: # what exceptions might be raised here? + # raise ValueError return tag_dict \ No newline at end of file From 5523eda8f78ee9db6eada010602650d2a7489e8d Mon Sep 17 00:00:00 2001 From: Neil Massey Date: Wed, 8 Mar 2023 12:10:13 +0000 Subject: [PATCH 38/41] Fixed PUT and PUTLIST hanging on INDEXING when holding_id=a holding id that doesn't exist --- nlds_processors/catalog/catalog_worker.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/nlds_processors/catalog/catalog_worker.py b/nlds_processors/catalog/catalog_worker.py index 0209cf85..5cd3ac33 100644 --- a/nlds_processors/catalog/catalog_worker.py +++ b/nlds_processors/catalog/catalog_worker.py @@ -157,8 +157,15 @@ def _catalog_put(self, body: dict, rk_origin: str) -> None: holding = self.catalog.get_holding(user, group, label, holding_id) except (KeyError, CatalogError): holding = None - if holding is None: + # if the holding_id is not None then raise an error as the user is + # trying to add to a holding that doesn't exist, but creating a new + # holding won't have a holding_id that matches the one they passed in + if (holding_id is not None): + message = (f"Could not add files to holding with holding_id: " + "{holding_id}. holding_id does not exist.") + self.log(message, self.RK_LOG_DEBUG) + raise CallbackError(message) try: holding = self.catalog.create_holding(user, group, label) except CatalogError as e: From 92585333459143cb1081af6f478fd51574e7bf36 Mon Sep 17 00:00:00 2001 From: Jack Leland Date: Thu, 9 Mar 2023 09:55:09 +0000 Subject: [PATCH 39/41] Make user holding permissions function static --- nlds_processors/catalog/catalog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nlds_processors/catalog/catalog.py b/nlds_processors/catalog/catalog.py index fa6c5b8c..336b7cdb 100644 --- a/nlds_processors/catalog/catalog.py +++ b/nlds_processors/catalog/catalog.py @@ -25,8 +25,8 @@ def __init__(self, db_engine: str, db_options: str): self.session = None - def _user_has_get_holding_permission(self, - user: str, + @staticmethod + def _user_has_get_holding_permission(user: str, group: str, holding: Holding) -> bool: """Check whether a user has permission to view this holding. From c97887e4cc07422c6bbf3e97f441eb6e7b4071a8 Mon Sep 17 00:00:00 2001 From: Jack Leland Date: Mon, 13 Mar 2023 15:42:42 +0000 Subject: [PATCH 40/41] Add some unit tests for db-mixin, catalog and monitor --- tests/nlds_processors/catalog/test_catalog.py | 574 ++++++++++++++++++ .../catalog/test_catalog_worker.py | 20 + tests/nlds_processors/monitor/test_monitor.py | 56 ++ tests/nlds_processors/test_db_mixin.py | 76 +++ 4 files changed, 726 insertions(+) create mode 100644 tests/nlds_processors/catalog/test_catalog.py create mode 100644 tests/nlds_processors/catalog/test_catalog_worker.py create mode 100644 tests/nlds_processors/monitor/test_monitor.py create mode 100644 tests/nlds_processors/test_db_mixin.py diff --git a/tests/nlds_processors/catalog/test_catalog.py b/tests/nlds_processors/catalog/test_catalog.py new file mode 100644 index 00000000..5d3b9384 --- /dev/null +++ b/tests/nlds_processors/catalog/test_catalog.py @@ -0,0 +1,574 @@ +import uuid +import time + +import pytest +from sqlalchemy import func + +from nlds_processors.catalog.catalog_models import ( + CatalogBase, File, Holding, Location, Transaction, Storage, Checksum, Tag +) +from nlds_processors.catalog.catalog import Catalog, CatalogError +from nlds.details import PathType + +test_uuid = '00a246cf-e2a8-46f0-baca-be3972fc4034' + +@pytest.fixture() +def mock_catalog(): + # Manually set some settings for a separate test db + db_engine = "sqlite" + db_options = { + "db_name" : "", + "db_user" : "", + "db_passwd" : "", + "echo": False + } + catalog = Catalog(db_engine, db_options) + catalog.connect() + catalog.start_session() + yield catalog + catalog.save() + catalog.end_session() + +@pytest.fixture() +def mock_holding(): + return Holding( + label='test-label', + user='test-user', + group='test-group', + ) + +@pytest.fixture() +def mock_transaction(): + return Transaction( + holding_id=None, + transaction_id=test_uuid, + ingest_time=func.now(), + ) + +@pytest.fixture() +def mock_file(): + new_file = File( + transaction_id=None, + original_path='/test/path', + path_type=PathType['FILE'], + link_path = None, + size=1050, + user='test-user', + group='test-group', + file_permissions='0o01577' + ) + return new_file + +@pytest.fixture() +def filled_mock_catalog(mock_catalog, mock_holding, mock_transaction): + mock_catalog.session.add(mock_holding) + mock_catalog.session.commit() + + mock_catalog.session.add(mock_transaction) + mock_catalog.session.commit() + + yield mock_catalog + + +class TestCatalog: + + def test_get_holding(self, mock_catalog): + # try on an empty database, should fail + with pytest.raises(CatalogError): + holding = mock_catalog.get_holding(user='', group='') + with pytest.raises(CatalogError): + holding = mock_catalog.get_holding(user='asgasg', group='assag') + + # First need to add a valid holding to the db + valid_holding = Holding( + label='test-label', + user='test-user', + group='test-group', + ) + mock_catalog.session.add(valid_holding) + mock_catalog.session.commit() + + # Attempt to get the valid holding from the test db + # Should work with just the user and group + holding = mock_catalog.get_holding(user='test-user', + group='test-group') + assert len(holding) == 1 + + # Should similarly work with correct label or regex search for label + holding = mock_catalog.get_holding(user='test-user', group='test-group', + label='test-label') + holding = mock_catalog.get_holding(user='test-user', group='test-group', + label='.*') + + # Try with incorrect information, should fail + with pytest.raises(CatalogError): + holding = mock_catalog.get_holding(user='incorrect-user', + group='test-group') + with pytest.raises(CatalogError): + holding = mock_catalog.get_holding(user='test-user', + group='incorrect-group') + with pytest.raises(CatalogError): + holding = mock_catalog.get_holding(user='incorrect-user', + group='incorrect-group') + with pytest.raises(CatalogError): + holding = mock_catalog.get_holding(user='test-user', + group='test-group', + label='incorrect-label') + # Should also fail with incorrect regex in label + with pytest.raises(CatalogError): + holding = mock_catalog.get_holding(user='test-user', + group='test-group', + label='*') + + # We can now add another Holding with the same user and group, but a + # different label and attempt to get that. + # First need to add a valid holding to the db + valid_holding = Holding( + label='test-label-2', + user='test-user', + group='test-group', + ) + mock_catalog.session.add(valid_holding) + mock_catalog.session.commit() + + # Should work with correct label + holding = mock_catalog.get_holding(user='test-user', group='test-group', + label='test-label') + # NOTE: returns 2 because test-label appears in both labels and this is + # automatically getting regexed + assert len(holding) == 2 + # Have to use proper regex to just get 1 + holding = mock_catalog.get_holding(user='test-user', group='test-group', + label='test-label$') + assert len(holding) == 1 + + # And with no label specified? + holding = mock_catalog.get_holding(user='test-user', group='test-group') + assert len(holding) == 2 + + # And with regex + holding = mock_catalog.get_holding(user='test-user', group='test-group', + label='test-label') + assert len(holding) == 2 + + # Can try getting by other parameters like holding_id and tag + holding = mock_catalog.get_holding(user='test-user', group='test-group', + holding_id=1) + assert len(holding) == 1 + holding = mock_catalog.get_holding(user='test-user', group='test-group', + holding_id=2) + assert len(holding) == 1 + + with pytest.raises(CatalogError): + mock_catalog.get_holding(user='test-user', group='test-group', + holding_id=3) + # TODO: tag logic? + + + def test_create_holding(self, mock_catalog): + # Can attempt to create a holding and then get it + mock_catalog.create_holding(user='test-user', group='test-group', + label='test-label') + # NOTE: Would be wise to directly interface with the database here to + # make sure the unit-test is isolated? + holding = mock_catalog.session.query(Holding).filter( + Holding.user == 'test-user', + Holding.group == 'test-group', + Holding.label == 'test-label', + ).all() + assert len(holding) == 1 + + # Attempt to add another identical item, should fail + with pytest.raises(CatalogError): + mock_catalog.create_holding(user='test-user', group='test-group', + label='test-label') + # need to rollback as the session is otherwise stuck + mock_catalog.session.rollback() + + + def test_modify_holding_with_empty_db(self, mock_catalog): + # modify_holding requires passing a Holding object to be passed in, so + # here we'll try with a None instead. Attempting to modify an invalid + # holding, should result in a CatalogError + with pytest.raises(CatalogError): + holding = mock_catalog.modify_holding(None, new_label='new-label') + # NOTE: the following fail with AttributeErrors as they're not properly + # input filtering, might be good to be more robust here and make these + # return catalog errors? + with pytest.raises(CatalogError): + holding = mock_catalog.modify_holding(None, new_tags={'key': 'val'}) + with pytest.raises(CatalogError): + holding = mock_catalog.modify_holding(None, del_tags={'key': 'val'}) + + + def test_modify_holding(self, mock_catalog): + # create a holding to modify + valid_holding = Holding( + label='test-label', + user='test-user', + group='test-group', + ) + # Before it's committed to the database, see if we can modify the label + holding = mock_catalog.modify_holding(valid_holding, + new_label='new-label') + assert holding.label == 'new-label' + # Commit and then attempt to modify again + mock_catalog.session.add(valid_holding) + mock_catalog.session.commit() + + holding = mock_catalog.modify_holding(valid_holding, + new_label='new-label-2') + assert holding.label == 'new-label-2' + # Attempting to change or delete the tags of a tagless holding should + # create new tags + valid_holding = mock_catalog.modify_holding(valid_holding, + new_tags={'key': 'val'}) + assert len(valid_holding.tags) == 1 + # Get the tags as a dict + tags = valid_holding.get_tags() + assert 'key' in tags + assert tags['key'] == 'val' + + # Can now modify the existing tag and check it's worked + valid_holding = mock_catalog.modify_holding(valid_holding, + new_tags={'key': 'newval'}) + assert len(valid_holding.tags) == 1 + # Get the tags as a dict + tags = valid_holding.get_tags() + assert 'key' in tags + assert tags['key'] == 'newval' + + # Can now attempt to remove the tags. + # Removing the tag that exists but has a different value should work but + # not do anything + valid_holding = mock_catalog.modify_holding(valid_holding, + del_tags={'key': 'val'}) + assert len(valid_holding.tags) == 1 + # Need to commit for changes to take effect + mock_catalog.session.commit() + assert len(valid_holding.tags) == 1 + + # Removing the actual tag should work + valid_holding = mock_catalog.modify_holding(valid_holding, + del_tags={'key': 'newval'}) + assert len(valid_holding.tags) == 1 + # Need to commit for changes to take effect + mock_catalog.session.commit() + assert len(valid_holding.tags) == 0 + + # Deleting a tag that doesn't exist shouldn't work + with pytest.raises(CatalogError): + valid_holding = mock_catalog.modify_holding(valid_holding, + del_tags={'key': 'val'}) + assert len(valid_holding.tags) == 0 + # Deleting or modifying without a valid dict should also break. Unlikely + # to occur in practice and raises a Type error which would be caught by + # the consumer. + with pytest.raises(TypeError): + valid_holding = mock_catalog.modify_holding(valid_holding, + new_tags='String') + # TODO: interestingly this raises a CatalogError, as opposed to a + # TypeError, should probably standardise this. + with pytest.raises(CatalogError): + valid_holding = mock_catalog.modify_holding(valid_holding, + del_tags='String') + + # Finally, attempting to do all of the actions at once should work the + # same as individually + holding = mock_catalog.modify_holding(valid_holding, + new_label='new-label-3', + new_tags={'key-3': 'val-3'}, + del_tags={'key-3': 'val-3'}) + assert holding.label == 'new-label-3' + # Interestingly, the all-at-once behaviour is different from the one-at- + # a time behaviour: the tag is deleted without the need for a commit. + # TODO: should look into this! + assert len(holding.tags) == 0 + + + def test_get_transaction(self, mock_catalog): + test_uuid = str(uuid.uuid4()) + + # try on an empty database, should probably fail but currently doesn't + # because of the one_or_none() on the query in the function. + transaction = mock_catalog.get_transaction(id=1) + assert transaction is None + transaction = mock_catalog.get_transaction(transaction_id=test_uuid) + assert transaction is None + + # add a transaction to later get + transaction = Transaction( + holding_id = 2, # doesn't exist yet + transaction_id = test_uuid, + ) + mock_catalog.session.add(transaction) + mock_catalog.session.commit() + + # Should be able to get by id or transaction_id + g_transaction = mock_catalog.get_transaction(id=1) + assert transaction == g_transaction + assert transaction.id == g_transaction.id + assert transaction.transaction_id == g_transaction.transaction_id + g_transaction = mock_catalog.get_transaction(transaction_id=test_uuid) + assert transaction == g_transaction + assert transaction.id == g_transaction.id + assert transaction.transaction_id == g_transaction.transaction_id + + # Try getting a non-existent transaction now we have something in the db + other_uuid = str(uuid.uuid4) + g_transaction = mock_catalog.get_transaction(transaction_id=other_uuid) + assert g_transaction is None + + + def test_create_transaction(self, mock_catalog, mock_holding): + test_uuid = str(uuid.uuid4()) + test_uuid_2 = str(uuid.uuid4()) + + # Attempt to create a new transaction, technically requires a Holding. + # First try with no holding, fails when it tries to reference the + # holding + with pytest.raises(AttributeError): + transaction = mock_catalog.create_transaction( + holding=None, + transaction_id=test_uuid + ) + + # create a holding to create the transaction with + holding = mock_holding + mock_catalog.session.add(holding) + mock_catalog.session.commit() + + # Can now make as many transactions as we want, assuming different uuids + transaction = mock_catalog.create_transaction(holding, test_uuid) + transaction_2 = mock_catalog.create_transaction(holding, test_uuid_2) + assert transaction != transaction_2 + + # Using the same uuid should probbaly result in an error but currently + # doesn't + # with pytest.raises(CatalogError): + transaction_3 = mock_catalog.create_transaction(holding, test_uuid) + + + def test_user_has_get_holding_permission(self): + # Leaving this for now until it's a bit more fleshed out + pass + + def test_user_has_get_file_permission(self): + # Leaving this for now until it's a bit more fleshed out + pass + + def test_get_file(self, mock_catalog, mock_holding, mock_transaction, + mock_file): + test_uuid = str(uuid.uuid4()) + catalog = mock_catalog + + # Add mock holding and transaction to db so ids are populated. + catalog.session.add(mock_holding) + catalog.session.flush() + mock_transaction.holding_id = mock_holding.id + catalog.session.add(mock_transaction) + catalog.session.flush() + + # Getting shouldn't work on an empty database + with pytest.raises(CatalogError): + file_ = catalog.get_file('test-user', 'test-group', '/test/path') + # Similarly shouldn't work if we specify a holding + with pytest.raises(CatalogError): + file_ = catalog.get_file('test-user', 'test-group', '/test/path', + holding=mock_holding) + + # But it will return without a fault if the flag is provided. + file_ = catalog.get_file('test-user', 'test-group', '/test/path', + missing_error_fl=False) + assert file_ is None + + # And, similarly, should return None if we do the same for a specific + # holding + file_ = catalog.get_file('test-user', 'test-group', '/test/path', + holding=mock_holding, missing_error_fl=False) + assert file_ is None + + # Make a file for us to get + new_file = mock_file + new_file.transaction_id = mock_transaction.id + catalog.session.add(new_file) + catalog.session.commit() + + # Should now work + file_ = catalog.get_file('test-user', 'test-group', '/test/path') + # Should still work if we provide a holding + same_file = catalog.get_file('test-user', 'test-group', '/test/path', + mock_holding) + assert file_ == same_file + + # Now we can add another holding, transaction and file with the same + # path to test which one is provided + holding_2 = Holding( + label='test-label-2', + user='test-user', + group='test-group', + ) + catalog.session.add(holding_2) + catalog.session.flush() + + # Sleep so that the ingest time is different t + time.sleep(1) + transaction_2 = Transaction( + holding_id=holding_2.id, + transaction_id=test_uuid, + ingest_time=func.now(), + ) + catalog.session.add(transaction_2) + catalog.session.flush() + file_2 = File( + transaction_id=transaction_2.id, + original_path='/test/path', + path_type=PathType['FILE'], + link_path = None, + size=99999, + user='test-user', + group='test-group', + file_permissions='0o01577' + ) + catalog.session.add(file_2) + catalog.session.commit() + + # Verify that the ingest times are different else the test won't work + assert transaction_2.ingest_time > mock_transaction.ingest_time + + # Should now get the most recently added file (with the large size) + g_file = catalog.get_file('test-user', 'test-group', '/test/path') + assert g_file.size == 99999 + + # Should still be able to get the first file if we provide a holding + first_file = catalog.get_file('test-user', 'test-group', '/test/path', + mock_holding) + assert first_file.size == 1050 + + # An invalid path should raise an error too + with pytest.raises(CatalogError): + files = catalog.get_file('test-user', 'test-group', 'invalidpath') + + + def test_get_files(self, mock_catalog, mock_holding, mock_transaction, + mock_file): + test_uuid = str(uuid.uuid4()) + catalog = mock_catalog + + # Getting shouldn't work on an empty database + with pytest.raises(CatalogError): + files = catalog.get_files('test-user', 'test-group') + # Try with garbage input + with pytest.raises(CatalogError): + files = catalog.get_files('ihasidg', 'oihaosifh') + # Try with reasonable values in all optional kwargs + with pytest.raises(CatalogError): + files = catalog.get_files( + 'test-user', + 'test-group', + holding_label='test-label', + holding_id=1, + transaction_id=test_uuid, + path='/test/path', + tag={'key': 'val'}, + ) + # Try with garbage in all optional kwargs + with pytest.raises(CatalogError): + files = catalog.get_files( + 'asgad', + 'agdasd', + holding_label='ououg', + holding_id='asfasf', + transaction_id='adgouihoih', + path='oihosidhag', + tag={'aegaa': 'as'}, + ) + + # Add mock holding and transaction to db so ids are populated. + catalog.session.add(mock_holding) + catalog.session.flush() + mock_transaction.holding_id = mock_holding.id + catalog.session.add(mock_transaction) + catalog.session.flush() + + # Getting still shouldn't work on the now initialised database + with pytest.raises(CatalogError): + files = catalog.get_files('test-user', 'test-group') + # Try with garbage input + with pytest.raises(CatalogError): + files = catalog.get_files('ihasidg', 'oihaosifh') + # Try with reasonable values in all optional kwargs + with pytest.raises(CatalogError): + files = catalog.get_files( + 'test-user', + 'test-group', + holding_label='test-label', + holding_id=1, + transaction_id=test_uuid, + path='/test/path', + tag={'key': 'val'}, + ) + # Try with garbage in all optional kwargs + with pytest.raises(CatalogError): + files = catalog.get_files( + 'asgad', + 'agdasd', + holding_label='ououg', + holding_id='asfasf', + transaction_id='adgouihoih', + path='oihosidhag', + tag={'aegaa': 'as'}, + ) + + # Make a file for us to get + new_file = mock_file + new_file.transaction_id = mock_transaction.id + catalog.session.add(new_file) + catalog.session.commit() + + files = catalog.get_files('test-user', 'test-group') + assert isinstance(files, list) + assert len(files) == 1 + assert files[0] == new_file + + # Add some more files with the same user + for i in range(10): + new_file_2 = File( + transaction_id=mock_transaction.id, + original_path=f'/test/path-{i}', + path_type=PathType['FILE'], + link_path = None, + size=1050, + user='test-user', + group='test-group', + file_permissions='0o01577' + ) + catalog.session.add(new_file_2) + catalog.session.commit() + files = catalog.get_files('test-user', 'test-group') + assert isinstance(files, list) + assert len(files) == i+2 + + def test_create_file(self): + pass + + def test_delete_files(self): + pass + + def test_get_location(self): + pass + + def test_create_location(self): + pass + + def test_create_tag(self): + pass + + def test_get_tag(self): + pass + + def test_modify_tag(self): + pass + + def test_del_tag(self): + pass diff --git a/tests/nlds_processors/catalog/test_catalog_worker.py b/tests/nlds_processors/catalog/test_catalog_worker.py new file mode 100644 index 00000000..cd79a313 --- /dev/null +++ b/tests/nlds_processors/catalog/test_catalog_worker.py @@ -0,0 +1,20 @@ +import pytest +import functools + +from nlds.rabbit import publisher as publ +import nlds.rabbit.consumer as cons +from nlds.details import PathDetails +from nlds_processors.catalog.catalog_worker import CatalogConsumer + +def mock_load_config(template_config): + return template_config + +@pytest.fixture() +def default_catalog(monkeypatch, template_config): + # Ensure template is loaded instead of .server_config + monkeypatch.setattr(publ, "load_config", functools.partial( + mock_load_config, + template_config + ) + ) + return CatalogConsumer() \ No newline at end of file diff --git a/tests/nlds_processors/monitor/test_monitor.py b/tests/nlds_processors/monitor/test_monitor.py new file mode 100644 index 00000000..3a9234f7 --- /dev/null +++ b/tests/nlds_processors/monitor/test_monitor.py @@ -0,0 +1,56 @@ +import pytest + +from nlds_processors.monitor.monitor_models import ( + MonitorBase, TransactionRecord, SubRecord, FailedFile, Warning +) +from nlds_processors.monitor.monitor import Monitor, MonitorError + +@pytest.fixture() +def mock_monitor(): + # Manually set some settings for test db in memory, very basic. + db_engine = "sqlite" + db_options = { + "db_name" : "", + "db_user" : "", + "db_passwd" : "", + "echo": False + } + # Set up + monitor = Monitor(db_engine, db_options) + monitor.connect() + monitor.start_session() + + # Provide to method + yield monitor + + # Tear down + monitor.save() + monitor.end_session() + + +def test_create_transaction_record(mock_monitor): + pass + +def test_get_transaction_record(mock_monitor): + pass + +def test_create_sub_record(mock_monitor): + pass + +def test_get_sub_record(mock_monitor): + pass + +def test_get_sub_records(mock_monitor): + pass + +def test_update_sub_record(mock_monitor): + pass + +def test_create_failed_file(mock_monitor): + pass + +def test_check_completion(mock_monitor): + pass + +def test_create_warning(mock_monitor): + pass diff --git a/tests/nlds_processors/test_db_mixin.py b/tests/nlds_processors/test_db_mixin.py new file mode 100644 index 00000000..f6ff46fc --- /dev/null +++ b/tests/nlds_processors/test_db_mixin.py @@ -0,0 +1,76 @@ +import pytest + +from nlds_processors.db_mixin import DBMixin, DBError + +class MockDBMixinInheritor(DBMixin): + """Mock class for testing DBMixin functions in isolation""" + def __init__(self, db_engine, db_options): + # Create minimum required attributes to create a working inherited class + # of DBMixin + self.db_engine = None + self.db_engine_str = db_engine + self.db_options = db_options + self.sessions = None + +class TestCatalogCreation(): + + def test_connect(self): + # Use non-sensical db_engine + db_engine = "gibberish" + db_options = { + "db_name" : "/test.db", + "db_user" : "", + "db_passwd" : "", + "echo": False + } + mock_dbmixin = MockDBMixinInheritor(db_engine, db_options) + # Should not work as we're trying to use non-sensical db config options + with pytest.raises(DBError): + mock_dbmixin.connect() + + # Use non-sensical db_options + db_engine = "sqlite" + db_options = list() + mock_dbmixin = MockDBMixinInheritor(db_engine, db_options) + # Should not work, but should break when the db_string is made through + # trying to index like a dictionary and then subsequently calling len() + with pytest.raises((KeyError, TypeError)): + mock_dbmixin.connect() + + # What happens if we use an empty dict? + db_options = dict() + mock_dbmixin = MockDBMixinInheritor(db_engine, db_options) + # Should not work when it tries to index things that don't exist + with pytest.raises(KeyError): + mock_dbmixin.connect() + + # Try creating a database with functional parameters (no username or + # password) in memory + db_engine = "sqlite" + db_options = { + "db_name" : "", + "db_user" : "", + "db_passwd" : "", + "echo": False + } + mock_dbmixin = MockDBMixinInheritor(db_engine, db_options) + # Should not work as we've not got a Base to create tables from + with pytest.raises((AttributeError)): + mock_dbmixin.connect() + + # Try creating a local database with username and password + db_engine = "sqlite" + db_options = { + "db_name" : "/test.db", + "db_user" : "test-un", + "db_passwd" : "test-pwd", + "echo": False + } + mock_dbmixin = MockDBMixinInheritor(db_engine, db_options) + # Should not work as we can't create a db file with a password or + # username using pysqlite (the default sqlite engine) + with pytest.raises((DBError)): + mock_dbmixin.connect() + + # TODO: Test with a mock SQLAlchemy.Base and see if we can break it in + # any interesting ways \ No newline at end of file From 20bd00b995b5a59494338b1d90337300831d1c20 Mon Sep 17 00:00:00 2001 From: Jack Leland Date: Mon, 13 Mar 2023 15:53:43 +0000 Subject: [PATCH 41/41] Fix for modify holding attribute error --- nlds_processors/catalog/catalog.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/nlds_processors/catalog/catalog.py b/nlds_processors/catalog/catalog.py index 336b7cdb..0831aedc 100644 --- a/nlds_processors/catalog/catalog.py +++ b/nlds_processors/catalog/catalog.py @@ -157,6 +157,11 @@ def modify_holding(self, del_tags: dict=None) -> Holding: """Find a holding and modify the information in it""" assert(self.session != None) + if not isinstance(holding, Holding): + raise CatalogError( + f"Cannot modify holding, it does not appear to be a valid " + f"Holding ({holding})." + ) # change the label if a new_label supplied if new_label: try: