Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions lib/galaxy/objectstore/irods.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
Object Store plugin for the Integrated Rule-Oriented Data System (iRODS)
"""

import functools
import logging
import os
import shutil
import ssl
import threading
import time
from datetime import datetime
from pathlib import Path

Expand All @@ -16,11 +18,13 @@
from irods.exception import (
CollectionDoesNotExist,
DataObjectDoesNotExist,
NetworkException,
)
from irods.session import iRODSSession
except ImportError:
irods = None


from galaxy.util import (
ExecutionTimer,
string_as_bool,
Expand All @@ -35,6 +39,40 @@
logging.getLogger("irods.connection").setLevel(logging.INFO) # irods logging generates gigabytes of logs


_IRODS_RETRY_ATTEMPTS = 3
_IRODS_RETRY_BACKOFF = 0.5

# python-irodsclient is optional; fall back to ssl errors when NetworkException is unavailable.
_RETRYABLE_CONNECTION_ERRORS: "tuple[type[BaseException], ...]"
if irods is None:
_RETRYABLE_CONNECTION_ERRORS = (ssl.SSLError,)
else:
_RETRYABLE_CONNECTION_ERRORS = (NetworkException, ssl.SSLError)


def _retry_on_connection_error(func):
"""Retry iRODS read operations on a transient connection error."""

@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, _IRODS_RETRY_ATTEMPTS + 1):
try:
return func(*args, **kwargs)
except _RETRYABLE_CONNECTION_ERRORS as exc:
if attempt == _IRODS_RETRY_ATTEMPTS:
raise
log.warning(
"Transient iRODS error in %s (attempt %d/%d), retrying on a fresh connection: %s",
func.__name__,
attempt,
_IRODS_RETRY_ATTEMPTS,
exc,
)
time.sleep(_IRODS_RETRY_BACKOFF * attempt)

return wrapper


def _config_xml_error(tag):
msg = f"No {tag} element in config XML tree"
raise Exception(msg)
Expand Down Expand Up @@ -376,6 +414,7 @@ def _config_to_dict(self):
}

# rel_path is file or folder?
@_retry_on_connection_error
def _get_remote_size(self, rel_path):
ipt_timer = ExecutionTimer()
p = Path(rel_path)
Expand All @@ -396,6 +435,7 @@ def _get_remote_size(self, rel_path):
log.debug("irods_pt _get_remote_size: %s", ipt_timer)

# rel_path is file or folder?
@_retry_on_connection_error
def _exists_remotely(self, rel_path):
ipt_timer = ExecutionTimer()
p = Path(rel_path)
Expand All @@ -415,6 +455,7 @@ def _exists_remotely(self, rel_path):
finally:
log.debug("irods_pt _exists_remotely: %s", ipt_timer)

@_retry_on_connection_error
def _download(self, rel_path):
ipt_timer = ExecutionTimer()
cache_path = self._get_cache_path(rel_path)
Expand Down
52 changes: 51 additions & 1 deletion test/unit/objectstore/test_irods.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import os
import ssl
import time

import pytest

from galaxy.objectstore.irods import parse_config_xml
from galaxy.objectstore.irods import (
_IRODS_RETRY_ATTEMPTS,
_retry_on_connection_error,
parse_config_xml,
)
from galaxy.util import parse_xml

SCRIPT_DIRECTORY = os.path.abspath(os.path.dirname(__file__))
Expand Down Expand Up @@ -80,3 +86,47 @@ def test_parse_config_xml_no_auth():
root = tree.getroot()
with pytest.raises(Exception, match="No auth element in config XML tree"):
parse_config_xml(root)


def _make_flaky(exc, fail_times):
calls = {"n": 0}

@_retry_on_connection_error
def op(_self):
calls["n"] += 1
if calls["n"] <= fail_times:
raise exc
return "ok"

return op, calls


@pytest.fixture(autouse=True)
def _no_sleep(monkeypatch):
monkeypatch.setattr(time, "sleep", lambda *_: None)


def test_retry_recovers_from_transient_error():
op, calls = _make_flaky(ssl.SSLEOFError("handshake"), fail_times=_IRODS_RETRY_ATTEMPTS - 1)
assert op(object()) == "ok"
assert calls["n"] == _IRODS_RETRY_ATTEMPTS


def test_retry_gives_up_after_max_attempts():
op, calls = _make_flaky(ssl.SSLEOFError("handshake"), fail_times=_IRODS_RETRY_ATTEMPTS)
with pytest.raises(ssl.SSLError):
op(object())
assert calls["n"] == _IRODS_RETRY_ATTEMPTS


def test_no_retry_on_success():
op, calls = _make_flaky(ssl.SSLEOFError("handshake"), fail_times=0)
assert op(object()) == "ok"
assert calls["n"] == 1


def test_no_retry_on_unrelated_error():
op, calls = _make_flaky(ValueError("boom"), fail_times=1)
with pytest.raises(ValueError):
op(object())
assert calls["n"] == 1
Loading