Repository: rucio/rucio

Issue:
Request with one tape and one multihop tape source will crash submitter
Motivation
----------
Suppose in the `get_transfer_requests_and_source_replicas` main loop, a request is added with a multihop tape source. Then in another iteration, a direct tape source is identified. We end up in https://github.com/rucio/rucio/blob/740b825cac747280ca159089bca4635264c07fc1/lib/rucio/core/transfer.py#L968
Since the first source was multihop, we clear `transfers[req_id]['sources']` at https://github.com/rucio/rucio/blob/740b825cac747280ca159089bca4635264c07fc1/lib/rucio/core/transfer.py#L1005-L1009
Then shortly after, if the second source is tape, we will end up in https://github.com/rucio/rucio/blob/740b825cac747280ca159089bca4635264c07fc1/lib/rucio/core/transfer.py#L1042-L1050
where there is an unchecked access to `transfers[req_id]['sources'][0]` that crashes.

Modification
------------
In this situation, I believe we still want to prefer the direct tape source over the multihop tape source and want to take the path at L1047. So I would suggest modifying L1045 to
```python
if len(transfers[req_id]["sources"]) and (
    transfers[req_id]["sources"][0][3] > ranking
    or (
        transfers[req_id]["sources"][0][3] == ranking
        and transfers[req_id]["sources"][0][4] <= link_ranking
    )
):
    continue
```

Transfers: Refactor list transfer requests
Since I was a bit annoyed by the returned tuple of `__list_transfer_requests_and_source_replicas` and floating variables to look up certain columns, I introduce a named tuple here. This should make further changes in these parts less painful.

Based on #4458


Discussion:

Discussed offline with @bziemons . I am also working on a functional refactoring in get_transfer_requests_and_source_replica. Which also depend on #4458. Once the dependency merged, we'll see with Ben to synchronize the two changes.

Resolving patch:
diff --git a/lib/rucio/core/request.py b/lib/rucio/core/request.py
--- a/lib/rucio/core/request.py
+++ b/lib/rucio/core/request.py
@@ -17,7 +17,7 @@
 # - Mario Lassnig <mario.lassnig@cern.ch>, 2013-2021
 # - Vincent Garonne <vincent.garonne@cern.ch>, 2013-2017
 # - Cedric Serfon <cedric.serfon@cern.ch>, 2014-2020
-# - Martin Barisits <martin.barisits@cern.ch>, 2014-2020
+# - Martin Barisits <martin.barisits@cern.ch>, 2014-2021
 # - Wen Guan <wen.guan@cern.ch>, 2014-2016
 # - Joaquín Bogado <jbogado@linti.unlp.edu.ar>, 2015-2019
 # - Thomas Beermann <thomas.beermann@cern.ch>, 2016-2021
@@ -28,16 +28,17 @@
 # - Andrew Lister <andrew.lister@stfc.ac.uk>, 2019
 # - Brandon White <bjwhite@fnal.gov>, 2019
 # - Benedikt Ziemons <benedikt.ziemons@cern.ch>, 2020-2021
-# - Matt Snyder <msnyder@bnl.gov>, 2021
 # - Radu Carpa <radu.carpa@cern.ch>, 2021
+# - Matt Snyder <msnyder@bnl.gov>, 2021
 
 import datetime
 import json
 import logging
 import time
 import traceback
+from collections import namedtuple
 from itertools import filterfalse
-from typing import TYPE_CHECKING, Set
+from typing import TYPE_CHECKING
 
 from six import string_types
 from sqlalchemy import and_, or_, func, update
@@ -58,11 +59,16 @@
 from rucio.db.sqla.session import read_session, transactional_session, stream_session
 from rucio.transfertool.fts3 import FTS3Transfertool
 
+RequestAndState = namedtuple('RequestAndState', ['request_id', 'request_state'])
+
 if TYPE_CHECKING:
-    from typing import List, Tuple, Iterable, Iterator, Optional, Callable, Sequence
+    from rucio.core.transfer import RequestWithSources
+    from typing import Any, Dict, Iterable, Iterator, List, Optional, Callable, Set, Union
     from sqlalchemy.orm import Session
 
-    RowIterator = Iterator[Sequence]
+    RequestResult = Dict[str, Any]
+    RequestResultOrState = Union[RequestResult, RequestAndState]
+    RowIterator = Iterator[RequestResult]
     ReduceFunction = Callable[[RowIterator], RowIterator]
 
 """
@@ -1544,38 +1550,33 @@ def list_requests(src_rse_ids, dst_rse_ids, states=[RequestState.WAITING], sessi
         yield request
 
 
-# important column indices from __list_transfer_requests_and_source_replicas
-request_id_col = 0
-activity_col = 7
-dest_rse_id_col = 10
-source_rse_id_col = 12
-distance_col = 20
-
-extra_transfertool_col = 21
-
-
 @transactional_session
-def preparer_update_requests(source_iter: "Iterable[Sequence]", session: "Optional[Session]" = None) -> int:
+def preparer_update_requests(source_iter: "Iterable[RequestResultOrState]", session: "Optional[Session]" = None) -> int:
+    """
+    Update transfer requests according to preparer settings.
+    """
     count = 0
-    for req_source in source_iter:
+    for rws in source_iter:
         update_dict = dict()
-        if len(req_source) == 2:
+        if isinstance(rws, RequestAndState):
             # special case where the first entry is the request id and the second is the new state
             # (see handling of RequestState.NO_SOURCES in reduce_requests)
-            update_dict[models.Request.state] = req_source[1]
+            request_id = rws.request_id
+            update_dict[models.Request.state] = rws.request_state
         else:
+            request_id = rws['request_id']
             update_dict[models.Request.state] = __throttler_request_state(
-                activity=req_source[activity_col],
-                source_rse_id=req_source[source_rse_id_col],
-                dest_rse_id=req_source[dest_rse_id_col],
+                activity=rws['activity'],
+                source_rse_id=rws['src_rse_id'],
+                dest_rse_id=rws['dest_rse_id'],
                 session=session,
             )
-            update_dict[models.Request.source_rse_id] = req_source[source_rse_id_col]
+            update_dict[models.Request.source_rse_id] = rws['src_rse_id']
 
-        if len(req_source) > extra_transfertool_col:
-            update_dict[models.Request.transfertool] = req_source[extra_transfertool_col]
+            if 'transfertool' in rws:
+                update_dict[models.Request.transfertool] = rws['transfertool']
 
-        session.query(models.Request).filter_by(id=req_source[request_id_col]).update(update_dict, synchronize_session=False)
+        session.query(models.Request).filter_by(id=request_id).update(update_dict, synchronize_session=False)
         count += 1
     return count
 
@@ -1615,19 +1616,30 @@ def __throttler_request_state(activity, source_rse_id, dest_rse_id, session: "Op
     return RequestState.WAITING if limit_found else RequestState.QUEUED
 
 
-def reduce_requests(req_sources: "List[Tuple]", sort_reduce_funcs: "List[ReduceFunction]", logger: "Callable") -> "RowIterator":
+def reduce_requests(
+    req_sources: "List[RequestWithSources]",
+    sort_reduce_funcs: "List[ReduceFunction]",
+    logger: "Callable",
+) -> "Iterator[RequestResultOrState]":
     """
-    Reduces the passed request sources tuples by using the sort-reduce functions,
-    returning the first of the remaining items or a simple
-    (request_id, RequestState.NO_SOURCES) tuple, if no sources were found.
+    Reduces the passed requests & sources objects by using the sort-reduce
+    functions, yielding the best RequestResult object or a RequestAndState
+    object. If all sources were filtered, a RequestAndState object with
+    RequestState.NO_SOURCES is yielded.
     """
     assert len(req_sources) != 0, 'parameter request sources must be non-empty'
 
-    # sort by Request.id for partitioning later
-    req_sources.sort(key=lambda t: t[request_id_col])
-
-    def pick_result(items: "List[Sequence]") -> "Optional[Sequence]":
-        result = items
+    def pick_result(rws: "RequestWithSources") -> "Optional[RequestResult]":
+        result = [
+            {
+                'request_id': rws.request_id,
+                'dest_rse_id': rws.dest_rse.id,
+                'activity': rws.activity,
+                'src_rse_id': source.rse.id,
+                'distance_ranking': source.distance_ranking
+            }
+            for source in rws.sources
+        ]
         debug_log = []
         for sort_reduce in sort_reduce_funcs:
             newresult = list(sort_reduce(result))
@@ -1635,28 +1647,20 @@ def pick_result(items: "List[Sequence]") -> "Optional[Sequence]":
             result = newresult
 
         if len(result) == 0:
-            logger(logging.WARNING, 'all available sources were filtered for requests with id %s', items[0][request_id_col])
+            logger(logging.WARNING, 'all available sources were filtered for requests with id %s', rws.request_id)
             logger(logging.DEBUG, 'the following filters ran:\n' + '\n'.join(debug_log))
         else:
             return result[0]
 
-    def result_or_no_sources(result: "Optional[Sequence]") -> "Sequence":
+    def result_or_no_sources(result: "Optional[RequestResult]") -> "RequestResultOrState":
         if result is None:
-            return (cur_request_id, RequestState.NO_SOURCES)
+            return RequestAndState(request_id=cur_request_id, request_state=RequestState.NO_SOURCES)
         else:
             return result
 
-    cur_request_id = req_sources[0][request_id_col]
-
-    # partition the req_sources by request_id and yield the best result from each group
-    current_items = [req_sources[0]]
-    for idx in range(1, len(req_sources)):
-        if cur_request_id != req_sources[idx][request_id_col]:
-            yield result_or_no_sources(pick_result(current_items))
-            cur_request_id = req_sources[idx][request_id_col]
-            current_items.clear()
-        current_items.append(req_sources[idx])
-    yield result_or_no_sources(pick_result(current_items))
+    for rws in req_sources:
+        cur_request_id = rws.request_id
+        yield result_or_no_sources(pick_result(rws))
 
 
 def get_supported_transfertools(rse_id: str, session=None) -> "Set[str]":
@@ -1675,37 +1679,38 @@ def get_supported_transfertools(rse_id: str, session=None) -> "Set[str]":
 
 
 def get_transfertool_filter(
-        get_transfertools: "Callable[[str], Set[str]]" = get_supported_transfertools
+    get_transfertools: "Callable[[str], Set[str]]" = get_supported_transfertools,
 ) -> "ReduceFunction":
     def filter_requests_for_transfertools(items: "RowIterator") -> "RowIterator":
         first = True
         first_request_id, first_dest_rse_id, dest_rse_transfertools = None, None, None
-        for t in items:
+        for rws_dict in items:
             if first:
                 first = False
-                first_request_id = t[request_id_col]
-                first_dest_rse_id = t[dest_rse_id_col]
+                first_request_id = rws_dict['request_id']
+                first_dest_rse_id = rws_dict['dest_rse_id']
                 dest_rse_transfertools = get_transfertools(first_dest_rse_id)
             else:
                 # same request id, same request destination rse in items per call
-                assert first_request_id == t[request_id_col]
-                assert first_dest_rse_id == t[dest_rse_id_col]
+                assert first_request_id == rws_dict['request_id']
+                assert first_dest_rse_id == rws_dict['dest_rse_id']
 
-            src_rse_transfertools = get_transfertools(t[source_rse_id_col])
+            src_rse_transfertools = get_transfertools(rws_dict['src_rse_id'])
             common_transfertools = dest_rse_transfertools.intersection(src_rse_transfertools)
             if common_transfertools:
                 if 'fts3' in common_transfertools and 'globus' in common_transfertools:
-                    transfertool = 'fts3'
+                    rws_dict['transfertool'] = 'fts3'
                 else:
-                    transfertool = common_transfertools.pop()
-                yield (*t, transfertool)
+                    rws_dict['transfertool'] = common_transfertools.pop()
+                yield rws_dict
 
     return filter_requests_for_transfertools
 
 
 def sort_requests_minimum_distance(items: "RowIterator") -> "RowIterator":
-    yield from sorted(items, key=lambda t: t[distance_col])
+    yield from sorted(items, key=lambda rws_dict: rws_dict['distance_ranking'])
 
 
 def rse_lookup_filter(items: "RowIterator") -> "RowIterator":
-    yield from filter(lambda row: row[source_rse_id_col] is not None and row[dest_rse_id_col] is not None, items)
+    yield from filter(lambda rws_dict: (rws_dict['src_rse_id'] is not None
+                                        and rws_dict['dest_rse_id'] is not None), items)
diff --git a/lib/rucio/core/transfer.py b/lib/rucio/core/transfer.py
--- a/lib/rucio/core/transfer.py
+++ b/lib/rucio/core/transfer.py
@@ -31,7 +31,7 @@
 # - Eli Chadwick <eli.chadwick@stfc.ac.uk>, 2020
 # - Nick Smith <nick.smith@cern.ch>, 2020
 # - Patrick Austin <patrick.austin@stfc.ac.uk>, 2020
-# - Benedikt Ziemons <benedikt.ziemons@cern.ch>, 2020
+# - Benedikt Ziemons <benedikt.ziemons@cern.ch>, 2020-2021
 # - Thomas Beermann <thomas.beermann@cern.ch>, 2021
 # - Radu Carpa <radu.carpa@cern.ch>, 2021
 
@@ -77,7 +77,7 @@
 from rucio.transfertool.mock import MockTransfertool
 
 if TYPE_CHECKING:
-    from typing import List, Tuple
+    from typing import List
 
 # Extra modules: Only imported if available
 EXTRA_MODULES = {'globus_sdk': False}
@@ -119,6 +119,309 @@
 WEBDAV_TRANSFER_MODE = config_get('conveyor', 'webdav_transfer_mode', False, None)
 
 
+class RseData:
+    """
+    Helper data class storing rse data grouped in one place.
+    """
+    def __init__(self, id, name=None, attributes=None, info=None):
+        self.id = id
+        self.name = name
+        self.attributes = attributes
+        self.info = info
+
+    def __str__(self):
+        if self.name is not None:
+            return "{}({})".format(self.name, self.id)
+        return self.id
+
+    def __eq__(self, other):
+        if other is None:
+            return False
+        return self.id == other.id
+
+    def is_tape(self):
+        if self.info['rse_type'] == RSEType.TAPE or self.info['rse_type'] == 'TAPE':
+            return True
+        return False
+
+    @read_session
+    def load_name(self, session=None):
+        if self.name is None:
+            self.name = get_rse_name(rse_id=self.id, session=session)
+        return self.name
+
+    @read_session
+    def load_attributes(self, session=None):
+        if self.attributes is None:
+            self.attributes = get_rse_attributes(self.id, session=session)
+        return self.attributes
+
+    @read_session
+    def load_info(self, session=None):
+        if self.info is None:
+            self.info = rsemgr.get_rse_info(rse=self.load_name(session=session),
+                                            vo=get_rse_vo(rse_id=self.id, session=session),
+                                            session=session)
+        return self.info
+
+
+class TransferSource:
+    def __init__(self, rse_data, source_ranking=None, distance_ranking=None, file_path=None, scheme=None):
+        self.rse = rse_data
+        self.distance_ranking = distance_ranking if distance_ranking is not None else 9999
+        self.source_ranking = source_ranking if source_ranking is not None else 0
+        self.file_path = file_path
+        self.scheme = scheme
+
+    def __str__(self):
+        return "source rse={}".format(self.rse)
+
+
+class TransferDestination:
+    def __init__(self, rse_data, scheme):
+        self.rse = rse_data
+        self.scheme = scheme
+
+    def __str__(self):
+        return "destination rse={}".format(self.rse)
+
+
+class RequestWithSources:
+    def __init__(self, id, rule_id, scope, name, md5, adler32, byte_count, activity, attributes,
+                 previous_attempt_id, dest_rse_data, account, retry_count):
+
+        self.request_id = id
+        self.rule_id = rule_id
+        self.scope = scope
+        self.name = name
+        self.md5 = md5
+        self.adler32 = adler32
+        self.byte_count = byte_count
+        self.activity = activity
+        self._dict_attributes = None
+        self._db_attributes = attributes
+        self.previous_attempt_id = previous_attempt_id
+        self.dest_rse = dest_rse_data
+        self.account = account
+        self.retry_count = retry_count
+
+        self.sources = []
+
+    def __str__(self):
+        return "request {}:{}({})".format(self.scope, self.name, self.request_id)
+
+    @property
+    def attributes(self):
+        if self._dict_attributes is None:
+            self.attributes = self._db_attributes
+        return self._dict_attributes
+
+    @attributes.setter
+    def attributes(self, db_attributes):
+        dict_attributes = {}
+        if db_attributes:
+            if isinstance(db_attributes, dict):
+                attr = json.loads(json.dumps(db_attributes))
+            else:
+                attr = json.loads(str(db_attributes))
+            # parse source expression
+            dict_attributes['source_replica_expression'] = attr["source_replica_expression"] if (attr and "source_replica_expression" in attr) else None
+            dict_attributes['allow_tape_source'] = attr["allow_tape_source"] if (attr and "allow_tape_source" in attr) else True
+            dict_attributes['dsn'] = attr["ds_name"] if (attr and "ds_name" in attr) else None
+            dict_attributes['lifetime'] = attr.get('lifetime', -1)
+        self._dict_attributes = dict_attributes
+
+
+class _RseLoaderContext:
+    """
+    Helper private class used to dynamically load and cache the rse information
+    """
+    def __init__(self, session):
+        self.session = session
+        self.rse_id_to_data_map = {}
+
+    def rse_data(self, rse_id):
+        rse_data = self.rse_id_to_data_map.get(rse_id)
+        if rse_data is None:
+            rse_data = RseData(rse_id)
+            rse_data.load_name(session=self.session)
+            rse_data.load_info(session=self.session)
+            rse_data.load_attributes(session=self.session)
+            self.rse_id_to_data_map[rse_id] = rse_data
+        return rse_data
+
+    def ensure_fully_loaded(self, rse_data):
+        if rse_data.name is None or rse_data.info is None or rse_data.attributes is None:
+            cached_rse_data = self.rse_data(rse_data.id)
+            if rse_data.name is None:
+                rse_data.name = cached_rse_data.name
+            if rse_data.info is None:
+                rse_data.info = cached_rse_data.info
+            if rse_data.attributes is None:
+                rse_data.attributes = cached_rse_data.attributes
+
+
+class ProtocolFactory:
+    """
+    Creates and caches protocol objects. Allowing to reuse them.
+    """
+    def __init__(self):
+        self.protocols = {}
+
+    def protocol(self, rse_data, scheme, operation):
+        protocol_key = '%s_%s_%s' % (operation, rse_data.id, scheme)
+        protocol = self.protocols.get(protocol_key)
+        if not protocol:
+            protocol = rsemgr.create_protocol(rse_data.info, operation, scheme)
+            self.protocols[protocol_key] = protocol
+        return protocol
+
+
+class DirectTransferDefinition:
+    """
+    The configuration for a direct (non-multi-hop) transfer. It can be a multi-source transfer.
+
+    The class wraps the legacy dict-based transfer definition to maintain compatibility with existing code
+    during the migration.
+    """
+    def __init__(self, source, destination, rws, protocol_factory, legacy_definition=None):
+        self.sources = [source]
+        self.destination = destination
+
+        self.rws = rws
+        self.protocol_factory = protocol_factory
+        self.legacy_def = legacy_definition or {}
+
+    def __str__(self):
+        return 'transfer {} from {} to {}'.format(self.rws, ' and '.join(self.sources), self.dst.rse)
+
+    @property
+    def src(self):
+        return self.sources[0]
+
+    @property
+    def dst(self):
+        return self.destination
+
+    def __setitem__(self, key, value):
+        self.legacy_def[key] = value
+
+    def __getitem__(self, key):
+        if key == 'dest_urls':
+            return [self._dest_url(self.dst, self.rws, self.protocol_factory)]
+        if key == 'sources':
+            return self.legacy_sources
+        if key == 'use_ipv4':
+            return self.use_ipv4
+        return self.legacy_def[key]
+
+    def get(self, key, default=None):
+        if key == 'dest_urls':
+            return [self._dest_url(self.dst, self.rws, self.protocol_factory)]
+        if key == 'sources':
+            return self.legacy_sources
+        if key == 'use_ipv4':
+            return self.use_ipv4
+        return self.legacy_def.get(key, default)
+
+    @property
+    def legacy_sources(self):
+        return [
+            (src.rse.name, self._source_url(src, self.dst, rws=self.rws, protocol_factory=self.protocol_factory), src.rse.id, src.source_ranking)
+            for src in self.sources
+        ]
+
+    @property
+    def use_ipv4(self):
+        # If any source or destination rse is ipv4 only
+        return self.dst.rse.attributes.get('use_ipv4', False) or any(src.rse.attributes.get('use_ipv4', False)
+                                                                     for src in self.sources)
+
+    @staticmethod
+    def __rewrite_source_url(source_url, source_sign_url, dest_sign_url, source_scheme):
+        """
+        Parametrize source url for some special cases of source and destination schemes
+        """
+        if dest_sign_url == 'gcs':
+            if source_scheme in ['davs', 'https']:
+                source_url += '?copy_mode=push'
+        elif dest_sign_url == 's3':
+            if source_scheme in ['davs', 'https']:
+                source_url += '?copy_mode=push'
+        elif WEBDAV_TRANSFER_MODE:
+            if source_scheme in ['davs', 'https']:
+                source_url += '?copy_mode=%s' % WEBDAV_TRANSFER_MODE
+
+        source_sign_url_map = {'gcs': 'gclouds', 's3': 's3s'}
+        if source_sign_url in source_sign_url_map:
+            if source_url[:7] == 'davs://':
+                source_url = source_sign_url_map[source_sign_url] + source_url[4:]
+            if source_url[:8] == 'https://':
+                source_url = source_sign_url_map[source_sign_url] + source_url[5:]
+
+        if source_url[:12] == 'srm+https://':
+            source_url = 'srm' + source_url[9:]
+        return source_url
+
+    @staticmethod
+    def __rewrite_dest_url(dest_url, dest_sign_url):
+        """
+        Parametrize destination url for some special cases of destination schemes
+        """
+        if dest_sign_url == 'gcs':
+            dest_url = re.sub('davs', 'gclouds', dest_url)
+            dest_url = re.sub('https', 'gclouds', dest_url)
+        elif dest_sign_url == 's3':
+            dest_url = re.sub('davs', 's3s', dest_url)
+            dest_url = re.sub('https', 's3s', dest_url)
+
+        if dest_url[:12] == 'srm+https://':
+            dest_url = 'srm' + dest_url[9:]
+        return dest_url
+
+    @classmethod
+    def _source_url(cls, src, dst, rws, protocol_factory):
+        """
+        Generate the source url which will be used as origin to copy the file from request rws towards the given dst endpoint
+        """
+        # Get source protocol
+        protocol = protocol_factory.protocol(src.rse, src.scheme, 'third_party_copy')
+
+        # Compute the source URL
+        source_sign_url = src.rse.attributes.get('sign_url', None)
+        dest_sign_url = dst.rse.attributes.get('sign_url', None)
+        source_url = list(protocol.lfns2pfns(lfns={'scope': rws.scope.external, 'name': rws.name, 'path': src.file_path}).values())[0]
+        source_url = cls.__rewrite_source_url(source_url, source_sign_url=source_sign_url, dest_sign_url=dest_sign_url, source_scheme=src.scheme)
+        return source_url
+
+    @classmethod
+    def _dest_url(cls, dst, rws, protocol_factory):
+        """
+        Generate the destination url for copying the file of request rws
+        """
+        # Get destination protocol
+        protocol = protocol_factory.protocol(dst.rse, dst.scheme, 'third_party_copy')
+
+        if dst.rse.info['deterministic']:
+            dest_url = list(protocol.lfns2pfns(lfns={'scope': rws.scope.external, 'name': rws.name}).values())[0]
+        else:
+            # compute dest url in case of non deterministic
+            # naming convention, etc.
+            dsn = get_dsn(rws.scope, rws.name, rws.attributes.get('dsn', None))
+            # DQ2 path always starts with /, but prefix might not end with /
+            naming_convention = dst.rse.attributes.get('naming_convention', None)
+            dest_path = construct_surl(dsn, rws.name, naming_convention)
+            if dst.rse.is_tape():
+                if rws.retry_count or rws.activity == 'Recovery':
+                    dest_path = '%s_%i' % (dest_path, int(time.time()))
+
+            dest_url = list(protocol.lfns2pfns(lfns={'scope': rws.scope.external, 'name': rws.name, 'path': dest_path}).values())[0]
+
+        dest_sign_url = dst.rse.attributes.get('sign_url', None)
+        dest_url = cls.__rewrite_dest_url(dest_url, dest_sign_url=dest_sign_url)
+        return dest_url
+
+
 def submit_bulk_transfers(external_host, files, transfertool='fts3', job_params={}, timeout=None, user_transfer_job=False, logger=logging.log):
     """
     Submit transfer request to a transfertool.
@@ -209,7 +512,7 @@ def prepare_sources_for_transfers(transfers, session=None):
                                        'submitted_at': datetime.datetime.utcnow()},
                                       synchronize_session=False)
             if rowcount == 0:
-                raise RequestNotFound("Failed to prepare transfer: request %s does not exist or is not in queued state" % (request_id))
+                raise RequestNotFound("Failed to prepare transfer: request %s does not exist or is not in queued state" % request_id)
 
             if 'file' in transfers[request_id]:
                 file = transfers[request_id]['file']
@@ -363,7 +666,7 @@ def set_transfer_update_time(external_host, transfer_id, update_time=datetime.da
         raise RucioException(error.args)
 
     if not rowcount:
-        raise UnsupportedOperation("Transfer %s doesn't exist or its status is not submitted." % (transfer_id))
+        raise UnsupportedOperation("Transfer %s doesn't exist or its status is not submitted." % transfer_id)
 
 
 def query_latest(external_host, state, last_nhours=1, logger=logging.log):
@@ -509,7 +812,7 @@ def get_hops(source_rse_id, dest_rse_id, include_multihop=False, multihop_rses=N
         limit_dest_schemes = []
 
     # Check if there is a cached result
-    result = REGION_SHORT.get('get_hops_%s_%s_%s' % (str(source_rse_id), str(dest_rse_id), ''.join(sorted(limit_dest_schemes))))
+    result = REGION_SHORT.get('get_hops_dist_%s_%s_%s' % (str(source_rse_id), str(dest_rse_id), ''.join(sorted(limit_dest_schemes))))
     if not isinstance(result, NoValue):
         return result
 
@@ -538,8 +841,10 @@ def get_hops(source_rse_id, dest_rse_id, include_multihop=False, multihop_rses=N
                      'source_scheme': matching_scheme[1],
                      'dest_scheme': matching_scheme[0],
                      'source_scheme_priority': matching_scheme[3],
-                     'dest_scheme_priority': matching_scheme[2]}]
-            REGION_SHORT.set('get_hops_%s_%s_%s' % (str(source_rse_id), str(dest_rse_id), ''.join(sorted(limit_dest_schemes))), path)
+                     'dest_scheme_priority': matching_scheme[2],
+                     'hop_distance': distance_graph[source_rse_id][dest_rse_id],
+                     'cumulated_distance': distance_graph[source_rse_id][dest_rse_id]}]
+            REGION_SHORT.set('get_hops_dist_%s_%s_%s' % (str(source_rse_id), str(dest_rse_id), ''.join(sorted(limit_dest_schemes))), path)
             return path
         except RSEProtocolNotSupported as error:
             if include_multihop:
@@ -579,8 +884,9 @@ def get_hops(source_rse_id, dest_rse_id, include_multihop=False, multihop_rses=N
                 # Check if the distance would be smaller
                 if distance_graph[current_node][out_v] is None:
                     continue
-                if visited_nodes.get(out_v, {'distance': 9999})['distance'] > current_distance + distance_graph[current_node][out_v] + HOP_PENALTY\
-                   and local_optimum > current_distance + distance_graph[current_node][out_v] + HOP_PENALTY:
+                new_adjacent_distance = current_distance + distance_graph[current_node][out_v] + HOP_PENALTY
+
+                if visited_nodes.get(out_v, {'distance': 9999})['distance'] > new_adjacent_distance and local_optimum > new_adjacent_distance:
                     # Check if the intermediate RSE is enabled for multihop
                     if out_v != dest_rse_id and out_v not in multihop_rses:
                         continue
@@ -592,13 +898,15 @@ def get_hops(source_rse_id, dest_rse_id, include_multihop=False, multihop_rses=N
                                                                       operation_dest='third_party_copy',
                                                                       domain='wan',
                                                                       scheme=limit_dest_schemes if out_v == dest_rse_id and limit_dest_schemes else None)
-                        visited_nodes[out_v] = {'distance': current_distance + distance_graph[current_node][out_v] + HOP_PENALTY,
+                        visited_nodes[out_v] = {'distance': new_adjacent_distance,
                                                 'path': current_path + [{'source_rse_id': current_node,
                                                                          'dest_rse_id': out_v,
                                                                          'source_scheme': matching_scheme[1],
                                                                          'dest_scheme': matching_scheme[0],
                                                                          'source_scheme_priority': matching_scheme[3],
-                                                                         'dest_scheme_priority': matching_scheme[2]}]}
+                                                                         'dest_scheme_priority': matching_scheme[2],
+                                                                         'hop_distance': distance_graph[current_node][out_v],
+                                                                         'cumulated_distance': new_adjacent_distance}]}
                         if out_v != dest_rse_id:
                             to_visit.append(out_v)
                         else:
@@ -606,27 +914,12 @@ def get_hops(source_rse_id, dest_rse_id, include_multihop=False, multihop_rses=N
                     except RSEProtocolNotSupported:
                         pass
     if dest_rse_id in visited_nodes:
-        REGION_SHORT.set('get_hops_%s_%s_%s' % (str(source_rse_id), str(dest_rse_id), ''.join(sorted(limit_dest_schemes))), visited_nodes[dest_rse_id]['path'])
+        REGION_SHORT.set('get_hops_dist_%s_%s_%s' % (str(source_rse_id), str(dest_rse_id), ''.join(sorted(limit_dest_schemes))), visited_nodes[dest_rse_id]['path'])
         return visited_nodes[dest_rse_id]['path']
     else:
         raise NoDistance()
 
 
-def get_attributes(attributes):
-    dict_attributes = {}
-    if attributes:
-        if isinstance(attributes, dict):
-            attr = json.loads(json.dumps(attributes))
-        else:
-            attr = json.loads(str(attributes))
-    # parse source expression
-    dict_attributes['source_replica_expression'] = attr["source_replica_expression"] if (attr and "source_replica_expression" in attr) else None
-    dict_attributes['allow_tape_source'] = attr["allow_tape_source"] if (attr and "allow_tape_source" in attr) else True
-    dict_attributes['dsn'] = attr["ds_name"] if (attr and "ds_name" in attr) else None
-    dict_attributes['lifetime'] = attr.get('lifetime', -1)
-    return dict_attributes
-
-
 def get_dsn(scope, name, dsn):
     if dsn:
         return dsn
@@ -637,69 +930,233 @@ def get_dsn(scope, name, dsn):
     return 'other'
 
 
-def __build_dest_url(scope, name, protocol, dest_rse_attrs, dest_is_deterministic, dest_is_tape, dict_attributes, retry_count, activity):
+def __find_compatible_direct_sources(sources, scheme, dest_rse_id, session=None):
     """
-    Private helper function to build destination URL when retrieving transfers to execute.
+    Find, among the sources, the ones which have a direct connection to dest_rse_id and are compatible
+    with the provided schemes.
+
+    Generates tuples of form (source, hop): the source and it's associated single-hop.
     """
+    inbound_distances = __load_inbound_distances_node(rse_id=dest_rse_id, session=session)
 
-    if dest_is_deterministic:
-        dest_url = list(protocol.lfns2pfns(lfns={'scope': scope.external, 'name': name}).values())[0]
-    else:
-        # compute dest url in case of non deterministic
-        # naming convention, etc.
-        dsn = get_dsn(scope, name, dict_attributes.get('dsn', None))
-        # DQ2 path always starts with /, but prefix might not end with /
-        naming_convention = dest_rse_attrs.get('naming_convention', None)
-        dest_path = construct_surl(dsn, name, naming_convention)
-        if dest_is_tape:
-            if retry_count or activity == 'Recovery':
-                dest_path = '%s_%i' % (dest_path, int(time.time()))
+    for source in sorted(sources, key=lambda s: (-s.source_ranking, s.distance_ranking)):
 
-        dest_url = list(protocol.lfns2pfns(lfns={'scope': scope.external, 'name': name, 'path': dest_path}).values())[0]
+        if source.rse.id not in inbound_distances:
+            # There is no direct connection between this source and the destination
+            continue
+
+        try:
+            matching_scheme = rsemgr.find_matching_scheme(
+                rse_settings_src=__load_rse_settings(rse_id=source.rse.id, session=session),
+                rse_settings_dest=__load_rse_settings(rse_id=dest_rse_id, session=session),
+                operation_src='third_party_copy',
+                operation_dest='third_party_copy',
+                domain='wan',
+                scheme=scheme)
+        except RSEProtocolNotSupported:
+            continue
+
+        hop = {'source_rse_id': source.rse.id,
+               'dest_rse_id': dest_rse_id,
+               'source_scheme': matching_scheme[1],
+               'dest_scheme': matching_scheme[0],
+               'source_scheme_priority': matching_scheme[3],
+               'dest_scheme_priority': matching_scheme[2],
+               'hop_distance': inbound_distances[source.rse.id],
+               'cumulated_distance': inbound_distances[source.rse.id]}
 
-    return dest_url
+        yield source, hop
 
 
-def __rewrite_source_url(source_url, source_sign_url, dest_sign_url, source_scheme):
+@transactional_session
+def __prepare_transfer_definition(ctx, protocol_factory, rws, source, transfertool, retry_other_fts, list_hops, bring_online, logger, session=None):
     """
-    Parametrize source url for some special cases of source and destination schemes
+    Create a hop-by-hop transfer configuration.
+    For each hop needed to replicate the file from source (source.rse) towards the request's destination (rws.dest_rse),
+    a DirectTransferDefinition object is constructed, which holds all the needed information to latter trigger a transfer
+    between hop source to hop destination.
     """
-    if dest_sign_url == 'gcs':
-        if source_scheme in ['davs', 'https']:
-            source_url += '?copy_mode=push'
-    elif dest_sign_url == 's3':
-        if source_scheme in ['davs', 'https']:
-            source_url += '?copy_mode=push'
-    elif WEBDAV_TRANSFER_MODE:
-        if source_scheme in ['davs', 'https']:
-            source_url += '?copy_mode=%s' % WEBDAV_TRANSFER_MODE
-
-    source_sign_url_map = {'gcs': 'gclouds', 's3': 's3s'}
-    if source_sign_url in source_sign_url_map:
-        if source_url[:7] == 'davs://':
-            source_url = source_sign_url_map[source_sign_url] + source_url[4:]
-        if source_url[:8] == 'https://':
-            source_url = source_sign_url_map[source_sign_url] + source_url[5:]
-
-    if source_url[:12] == 'srm+https://':
-        source_url = 'srm' + source_url[9:]
-    return source_url
-
-
-def __rewrite_dest_url(dest_url, dest_sign_url, dest_scheme):
+
+    if len(list_hops) > 1:
+        logger(logging.DEBUG, 'From %s to %s requires multihop: %s', source.rse, rws.dest_rse, list_hops)
+    transfer_path = []
+    for hop in list_hops:
+        src = TransferSource(
+            rse_data=ctx.rse_data(hop['source_rse_id']),
+            file_path=source.file_path if hop is list_hops[0] else None,
+            source_ranking=source.source_ranking if hop is list_hops[0] else 0,
+            distance_ranking=list_hops[-1]['cumulated_distance'] if hop is list_hops[-1] else hop['hop_distance'],
+            scheme=hop['source_scheme'],
+        )
+        dst = TransferDestination(
+            rse_data=ctx.rse_data(hop['dest_rse_id']),
+            scheme=hop['dest_scheme'],
+        )
+        hop_definition = DirectTransferDefinition(
+            source=src,
+            destination=dst,
+            rws=rws,
+            protocol_factory=protocol_factory,
+        )
+
+        # Extend the metadata dictionary with request attributes
+        transfer_src_type = "DISK"
+        transfer_dst_type = "DISK"
+        overwrite, bring_online_local = True, None
+        if src.rse.is_tape() or src.rse.attributes.get('staging_required', False):
+            bring_online_local = bring_online
+            transfer_src_type = "TAPE"
+        if dst.rse.is_tape():
+            overwrite = False
+            transfer_dst_type = "TAPE"
+
+        # Get dest space token
+        dest_protocol = protocol_factory.protocol(dst.rse, dst.scheme, 'third_party_copy')
+        dest_spacetoken = None
+        if dest_protocol.attributes and 'extended_attributes' in dest_protocol.attributes and \
+                dest_protocol.attributes['extended_attributes'] and 'space_token' in dest_protocol.attributes['extended_attributes']:
+            dest_spacetoken = dest_protocol.attributes['extended_attributes']['space_token']
+
+        # get external_host + strict_copy + archive timeout
+        strict_copy = dst.rse.attributes.get('strict_copy', False)
+        fts_hosts = dst.rse.attributes.get('fts', None)
+        archive_timeout = dst.rse.attributes.get('archive_timeout', None)
+        if src.rse.attributes.get('sign_url', None) == 'gcs':
+            fts_hosts = src.rse.attributes.get('fts', None)
+        source_globus_endpoint_id = src.rse.attributes.get('globus_endpoint_id', None)
+        dest_globus_endpoint_id = dst.rse.attributes.get('globus_endpoint_id', None)
+
+        if transfertool == 'fts3' and not fts_hosts:
+            logger(logging.ERROR, 'Destination RSE %s FTS attribute not defined - SKIP REQUEST %s', dst.rse, rws.request_id)
+            return
+        if transfertool == 'globus' and (not dest_globus_endpoint_id or not source_globus_endpoint_id):
+            logger(logging.ERROR, 'Destination RSE %s Globus endpoint attributes not defined - SKIP REQUEST %s', dst.rse, rws.request_id)
+            return
+        if rws.retry_count is None:
+            rws.retry_count = 0
+        external_host = ''
+        if fts_hosts:
+            fts_list = fts_hosts.split(",")
+            external_host = fts_list[0]
+
+        if retry_other_fts:
+            external_host = fts_list[rws.retry_count % len(fts_list)]
+
+        # Get the checksum validation strategy (none, source, destination or both)
+        verify_checksum = 'both'
+        if not dst.rse.attributes.get('verify_checksum', True):
+            if not src.rse.attributes.get('verify_checksum', True):
+                verify_checksum = 'none'
+            else:
+                verify_checksum = 'source'
+        else:
+            if not src.rse.attributes.get('verify_checksum', True):
+                verify_checksum = 'destination'
+            else:
+                verify_checksum = 'both'
+
+        src_rse_checksums = get_rse_supported_checksums(src.rse.id, session=session)
+        dst_rse_checksums = get_rse_supported_checksums(dst.rse.id, session=session)
+
+        common_checksum_names = set(src_rse_checksums).intersection(dst_rse_checksums)
+
+        if len(common_checksum_names) == 0:
+            logger(logging.INFO, 'No common checksum method. Verifying destination only.')
+            verify_checksum = 'destination'
+
+        # Fill the transfer dictionary including file_metadata
+        file_metadata = {'request_id': rws.request_id,
+                         'scope': rws.scope,
+                         'name': rws.name,
+                         'activity': rws.activity,
+                         'request_type': RequestType.TRANSFER,
+                         'src_type': transfer_src_type,
+                         'dst_type': transfer_dst_type,
+                         'src_rse': src.rse.name,
+                         'dst_rse': dst.rse.name,
+                         'src_rse_id': src.rse.id,
+                         'dest_rse_id': dst.rse.id,
+                         'filesize': rws.byte_count,
+                         'md5': rws.md5,
+                         'adler32': rws.adler32,
+                         'verify_checksum': verify_checksum,
+                         'source_globus_endpoint_id': source_globus_endpoint_id,
+                         'dest_globus_endpoint_id': dest_globus_endpoint_id}
+        transfer = {'request_id': rws.request_id,
+                    'schemes': __add_compatible_schemes(schemes=[dst.scheme], allowed_schemes=SUPPORTED_PROTOCOLS),
+                    'account': InternalAccount('root'),
+                    'src_spacetoken': None,
+                    'dest_spacetoken': dest_spacetoken,
+                    'overwrite': overwrite,
+                    'bring_online': bring_online_local,
+                    'copy_pin_lifetime': rws.attributes.get('lifetime', 172800),
+                    'external_host': external_host,
+                    'selection_strategy': 'auto',
+                    'rule_id': rws.rule_id,
+                    'file_metadata': file_metadata}
+        if len(list_hops) > 1:
+            transfer['multihop'] = True
+            transfer['initial_request_id'] = rws.request_id
+        if strict_copy:
+            transfer['strict_copy'] = strict_copy
+        if archive_timeout and dst.rse.is_tape():
+            try:
+                transfer['archive_timeout'] = int(archive_timeout)
+                logger(logging.DEBUG, 'Added archive timeout to transfer.')
+            except ValueError:
+                logger(logging.WARNING, 'Could not set archive_timeout for %s. Must be integer.', hop_definition)
+                pass
+        if hop is list_hops[-1]:
+            transfer['account'] = rws.account
+            if rws.previous_attempt_id:
+                file_metadata['previous_attempt_id'] = rws.previous_attempt_id
+
+        hop_definition.legacy_def = transfer
+        transfer_path.append(hop_definition)
+    return transfer_path
+
+
+def __pick_best_transfer(candidate_paths):
     """
-    Parametrize destination url for some special cases of destination schemes
+    Given a list of possible transfer paths from different sources towards the same destination,
+    pick the best transfer path.
     """
-    if dest_sign_url == 'gcs':
-        dest_url = re.sub('davs', 'gclouds', dest_url)
-        dest_url = re.sub('https', 'gclouds', dest_url)
-    elif dest_sign_url == 's3':
-        dest_url = re.sub('davs', 's3s', dest_url)
-        dest_url = re.sub('https', 's3s', dest_url)
 
-    if dest_url[:12] == 'srm+https://':
-        dest_url = 'srm' + dest_url[9:]
-    return dest_url
+    if not candidate_paths:
+        return
+
+    # Discard multihop transfers which contain a tape source as an intermediate hop
+    filtered_candidate_paths = []
+    for path in candidate_paths:
+        if any(transfer['file_metadata']['src_type'] == 'TAPE' for transfer in path[1:]):
+            continue
+        filtered_candidate_paths.append(path)
+    candidate_paths = filtered_candidate_paths
+
+    # Discard multihop transfers which contain other candidate as part of itself For example:
+    # if A->B->C and B->C are both candidates, discard A->B->C because it includes B->C. Doing B->C is enough.
+    source_rses = {path[0]['file_metadata']['src_rse_id'] for path in candidate_paths}
+    filtered_candidate_paths = []
+    for path in candidate_paths:
+        if any(transfer['file_metadata']['src_rse_id'] in source_rses for transfer in path[1:]):
+            continue
+        filtered_candidate_paths.append(path)
+    candidate_paths = filtered_candidate_paths
+
+    def __transfer_order_key(transfer_path):
+        # higher source_ranking first,
+        # on equal source_ranking, prefer DISK over TAPE
+        # on equal type, prefer lower distance_ranking
+        # on equal distance, prefer single hop
+        return (
+            - transfer_path[-1].src.source_ranking,
+            transfer_path[0]['file_metadata']['src_type'].lower(),  # rely on the fact that "disk" < "tape" in string order
+            transfer_path[-1].src.distance_ranking,
+            len(transfer_path) > 1,  # rely on the fact that False < True
+        )
+
+    best_candidate = min(candidate_paths, key=__transfer_order_key)
+    return best_candidate
 
 
 @transactional_session
@@ -721,108 +1178,65 @@ def get_transfer_requests_and_source_replicas(total_workers=0, worker_number=0,
     :param logger:                Optional decorated logger that can be passed from the calling daemons or servers.
     :param session:               The database session in use.
     :returns:                     transfers, reqs_no_source, reqs_scheme_mismatch, reqs_only_tape_source
-    """
-
-    req_sources = __list_transfer_requests_and_source_replicas(total_workers=total_workers,
-                                                               worker_number=worker_number,
-                                                               limit=limit,
-                                                               activity=activity,
-                                                               older_than=older_than,
-                                                               rses=rses,
-                                                               request_state=RequestState.QUEUED,
-                                                               transfertool=transfertool,
-                                                               session=session)
-
-    class _LocalContext:
-        def __init__(self, session):
-            self.session = session
-            self.rse_id_to_name_map = {}
-            self.rse_id_to_info_map = {}
-            self.rse_id_to_attrs_map = {}
-            self.protocols = {}
-
-        def _ensure_rse_loaded(self, rse_id):
-            if rse_id not in self.rse_id_to_name_map:
-                rse_name = get_rse_name(rse_id=rse_id, session=self.session)
-                self.rse_id_to_name_map[rse_id] = rse_name
-                self.rse_id_to_info_map[rse_id] = rsemgr.get_rse_info(rse=rse_name,
-                                                                      vo=get_rse_vo(rse_id=rse_id, session=self.session),
-                                                                      session=self.session)
-                self.rse_id_to_attrs_map[rse_id] = get_rse_attributes(rse_id, session=self.session)
-
-        def rse_name(self, rse_id):
-            self._ensure_rse_loaded(rse_id)
-            return self.rse_id_to_name_map[rse_id]
-
-        def rse_info(self, rse_id):
-            self._ensure_rse_loaded(rse_id)
-            return self.rse_id_to_info_map[rse_id]
-
-        def rse_attrs(self, rse_id):
-            self._ensure_rse_loaded(rse_id)
-            return self.rse_id_to_attrs_map[rse_id]
-
-        def is_tape_rse(self, rse_id):
-            _rse_info = self.rse_info(rse_id)
-            if _rse_info['rse_type'] == RSEType.TAPE or _rse_info['rse_type'] == 'TAPE':
-                return True
-            return False
 
-        def protocol(self, rse_id, scheme, operation):
-            protocol_key = '%s_%s_%s' % (operation, rse_id, scheme)
-            protocol = self.protocols.get(protocol_key)
-            if not protocol:
-                protocol = rsemgr.create_protocol(self.rse_info(rse_id), 'third_party_copy', scheme)
-                self.protocols[protocol_key] = protocol
-            return protocol
+    Workflow:
+    - retrieve (from the database) the transfer requests with their possible source replicas
+    - for each source, compute the (possibly multihop) path between it and the transfer destination
+    - pick the best path among the ones computed previously
+    - if the chosen best path is a single-hop from a DISK RSE, try building a multi-source transfer:
+         The scheme compatibility is important for multi-source transfers. We iterate again over the
+         single-hop sources and build a new transfer definition while enforcing the scheme compatibility
+         with the initial source.
+    - if the chosen best path is a multihop, create intermediate replicas and the intermediate transfer requests
+    """
 
-    ctx = _LocalContext(session)
+    request_with_sources = __list_transfer_requests_and_source_replicas(
+        total_workers=total_workers,
+        worker_number=worker_number,
+        limit=limit,
+        activity=activity,
+        older_than=older_than,
+        rses=rses,
+        request_state=RequestState.QUEUED,
+        transfertool=transfertool,
+        session=session,
+    )
+
+    ctx = _RseLoaderContext(session)
+    protocol_factory = ProtocolFactory()
     unavailable_read_rse_ids = __get_unavailable_rse_ids(operation='read', session=session)
     unavailable_write_rse_ids = __get_unavailable_rse_ids(operation='write', session=session)
 
-    bring_online_local = bring_online
-    transfers, reqs_no_source, reqs_only_tape_source, reqs_scheme_mismatch = {}, [], [], []
-    multi_hop_dict = {}
-
-    multihop_rses = []
-    try:
-        multihop_rses = [rse['id'] for rse in parse_expression('available_for_multihop=true')]
-    except InvalidRSEExpression:
-        multihop_rses = []
-
-    for req_id, rule_id, scope, name, md5, adler32, bytes, activity, attributes, previous_attempt_id, dest_rse_id, account, source_rse_id, rse, deterministic, rse_type, path, retry_count, src_url, ranking, link_ranking in req_sources:
-
-        if ranking is None:
-            ranking = 0
+    transfer_path_for_request, reqs_no_source, reqs_only_tape_source, reqs_scheme_mismatch = [], set(), set(), set()
 
-        multihop = False
+    include_multihop = False
+    if transfertool in ['fts3', None]:
+        include_multihop = core_config_get('transfers', 'use_multihop', default=False, expiration_time=600, session=session)
 
-        # Add req to req_no_source list (Will be removed later if needed)
-        if req_id not in reqs_no_source:
-            reqs_no_source.append(req_id)
-
-        # source_rse_id will be None if no source replicas
-        # rse will be None if rse is staging area
-        if source_rse_id is None or rse is None:
-            continue
+    multihop_rses = []
+    if include_multihop:
+        try:
+            multihop_rses = [rse['id'] for rse in parse_expression('available_for_multihop=true')]
+        except InvalidRSEExpression:
+            pass
 
-        if rses and dest_rse_id not in rses:
-            continue
+    for rws in request_with_sources:
 
-        dest_rse_name = ctx.rse_name(dest_rse_id)
-        source_rse_name = ctx.rse_name(source_rse_id)
+        ctx.ensure_fully_loaded(rws.dest_rse)
+        for source in rws.sources:
+            ctx.ensure_fully_loaded(source.rse)
 
-        dict_attributes = get_attributes(attributes)
+        # Assume request doesn't have any sources. Will be removed later if sources are found.
+        reqs_no_source.add(rws.request_id)
 
-        # Check if the source and destination are blocked
-        if source_rse_id in unavailable_read_rse_ids:
-            continue
-        if dest_rse_id in unavailable_write_rse_ids:
-            logger(logging.WARNING, 'RSE %s is blocked for write. Will skip the submission of new jobs', dest_rse_name)
+        # Check if destination is blocked
+        if rws.dest_rse.id in unavailable_write_rse_ids:
+            logger(logging.WARNING, 'RSE %s is blocked for write. Will skip the submission of new jobs', rws.dest_rse)
             continue
 
         # parse source expression
-        source_replica_expression = dict_attributes.get('source_replica_expression', None)
+        source_replica_expression = rws.attributes.get('source_replica_expression', None)
+        allowed_source_rses = None
         if source_replica_expression:
             try:
                 parsed_rses = parse_expression(source_replica_expression, session=session)
@@ -830,287 +1244,182 @@ def protocol(self, rse_id, scheme, operation):
                 logger(logging.ERROR, "Invalid RSE exception %s: %s", source_replica_expression, str(error))
                 continue
             else:
-                allowed_rses = [x['id'] for x in parsed_rses]
-                if source_rse_id not in allowed_rses:
-                    continue
-
-        # Call the get_hops function to create a list of RSEs used for the transfer
-        # In case the source_rse and the dest_rse are connected, the list contains only the destination RSE
-        # In case of non-connected, the list contains all the intermediary RSEs
-        list_hops = []
-        include_multihop = False
-        if transfertool in ['fts3', None]:
-            include_multihop = core_config_get('transfers', 'use_multihop', default=False, expiration_time=600, session=session)
-
-        try:
-            list_hops = get_hops(source_rse_id,
-                                 dest_rse_id,
-                                 include_multihop=include_multihop,
-                                 multihop_rses=multihop_rses,
-                                 limit_dest_schemes=transfers.get(req_id, {}).get('schemes', None),
-                                 session=session)
-            if len(list_hops) > 1:
-                logger(logging.DEBUG, 'From %s to %s requires multihop: %s', source_rse_id, dest_rse_id, list_hops)
-                multihop = True
-                multi_hop_dict[req_id] = (list_hops, dict_attributes, retry_count)
-        except NoDistance:
-            logger(logging.WARNING, "Request %s: no link from %s to %s", req_id, source_rse_name, dest_rse_name)
-            if req_id in reqs_scheme_mismatch:
-                reqs_scheme_mismatch.remove(req_id)
-            if req_id not in reqs_no_source:
-                reqs_no_source.append(req_id)
-            continue
-        except RSEProtocolNotSupported:
-            logger(logging.WARNING, "Request %s: no matching protocol between %s and %s", req_id, source_rse_name, dest_rse_name)
-            if req_id in reqs_no_source:
-                reqs_no_source.remove(req_id)
-            if req_id not in reqs_scheme_mismatch:
-                reqs_scheme_mismatch.append(req_id)
-            continue
-
-        source_scheme = list_hops[0]['source_scheme']
-        dest_scheme = list_hops[-1]['dest_scheme']
-        dest_scheme_priority = list_hops[-1]['dest_scheme_priority']
-
-        allow_tape_source = True
-        try:
-            # Get source protocol
-            source_protocol = ctx.protocol(source_rse_id, source_scheme, 'read')
-
-            source_sign_url = ctx.rse_attrs(source_rse_id).get('sign_url', None)
-            dest_sign_url = ctx.rse_attrs(dest_rse_id).get('sign_url', None)
-
-            # Compute the source URL
-            source_url = list(source_protocol.lfns2pfns(lfns={'scope': scope.external, 'name': name, 'path': path}).values())[0]
-            source_url = __rewrite_source_url(source_url, source_sign_url=source_sign_url, dest_sign_url=dest_sign_url, source_scheme=source_scheme)
-
-            # If the request_id is not already in the transfer dictionary, need to compute the destination URL
-            if req_id not in transfers:
-
-                # parse allow tape source expression, not finally version.
-                # allow_tape_source = attr["allow_tape_source"] if (attr and "allow_tape_source" in attr) else True
-                allow_tape_source = True
-
-                # Extend the metadata dictionary with request attributes
-                transfer_src_type = "DISK"
-                transfer_dst_type = "DISK"
-                overwrite, bring_online = True, None
-                if ctx.is_tape_rse(source_rse_id) or ctx.rse_attrs(source_rse_id).get('staging_required', False):
-                    bring_online = bring_online_local
-                    transfer_src_type = "TAPE"
-                    if not allow_tape_source:
-                        if req_id not in reqs_only_tape_source:
-                            reqs_only_tape_source.append(req_id)
-                        if req_id in reqs_no_source:
-                            reqs_no_source.remove(req_id)
-                        continue
-
-                if ctx.is_tape_rse(dest_rse_id):
-                    overwrite = False
-                    transfer_dst_type = "TAPE"
-
-                # Get destination protocol
-                dest_protocol = ctx.protocol(dest_rse_id, dest_scheme, 'write')
-
-                # Compute the destination url
-                dest_url = __build_dest_url(scope=scope, name=name,
-                                            protocol=dest_protocol,
-                                            dest_rse_attrs=ctx.rse_attrs(dest_rse_id),
-                                            dest_is_deterministic=ctx.rse_info(dest_rse_id)['deterministic'],
-                                            dest_is_tape=ctx.is_tape_rse(dest_rse_id),
-                                            dict_attributes=dict_attributes,
-                                            retry_count=retry_count,
-                                            activity=activity)
-                dest_url = __rewrite_dest_url(dest_url, dest_sign_url=dest_sign_url, dest_scheme=dest_scheme)
-
-                # Get dest space token
-                dest_spacetoken = None
-                if dest_protocol.attributes and 'extended_attributes' in dest_protocol.attributes and \
-                        dest_protocol.attributes['extended_attributes'] and 'space_token' in dest_protocol.attributes['extended_attributes']:
-                    dest_spacetoken = dest_protocol.attributes['extended_attributes']['space_token']
-
-                use_ipv4 = ctx.rse_attrs(source_rse_id).get('use_ipv4', False) or ctx.rse_attrs(dest_rse_id).get('use_ipv4', False)
-
-                # get external_host + strict_copy + archive timeout
-                strict_copy = ctx.rse_attrs(dest_rse_id).get('strict_copy', False)
-                fts_hosts = ctx.rse_attrs(dest_rse_id).get('fts', None)
-                archive_timeout = ctx.rse_attrs(dest_rse_id).get('archive_timeout', None)
-                if source_sign_url == 'gcs':
-                    fts_hosts = ctx.rse_attrs(source_rse_id).get('fts', None)
-                source_globus_endpoint_id = ctx.rse_attrs(source_rse_id).get('globus_endpoint_id', None)
-                dest_globus_endpoint_id = ctx.rse_attrs(dest_rse_id).get('globus_endpoint_id', None)
-
-                if transfertool == 'fts3' and not fts_hosts:
-                    logger(logging.ERROR, 'Destination RSE %s FTS attribute not defined - SKIP REQUEST %s', dest_rse_name, req_id)
-                    continue
-                if transfertool == 'globus' and (not dest_globus_endpoint_id or not source_globus_endpoint_id):
-                    logger(logging.ERROR, 'Destination RSE %s Globus endpoint attributes not defined - SKIP REQUEST %s', dest_rse_name, req_id)
-                    continue
-                if retry_count is None:
-                    retry_count = 0
-                external_host = ''
-                if fts_hosts:
-                    fts_list = fts_hosts.split(",")
-                    external_host = fts_list[0]
-
-                if retry_other_fts:
-                    external_host = fts_list[retry_count % len(fts_list)]
-
-                # Get the checksum validation strategy (none, source, destination or both)
-                verify_checksum = 'both'
-                if not ctx.rse_attrs(dest_rse_id).get('verify_checksum', True):
-                    if not ctx.rse_attrs(source_rse_id).get('verify_checksum', True):
-                        verify_checksum = 'none'
-                    else:
-                        verify_checksum = 'source'
-                else:
-                    if not ctx.rse_attrs(source_rse_id).get('verify_checksum', True):
-                        verify_checksum = 'destination'
-                    else:
-                        verify_checksum = 'both'
-
-                source_rse_checksums = get_rse_supported_checksums(source_rse_id, session=session)
-                dest_rse_checksums = get_rse_supported_checksums(dest_rse_id, session=session)
-
-                common_checksum_names = set(source_rse_checksums).intersection(dest_rse_checksums)
-
-                if len(common_checksum_names) == 0:
-                    logger(logging.INFO, 'No common checksum method. Verifying destination only.')
-                    verify_checksum = 'destination'
-
-                # Fill the transfer dictionary including file_metadata
-                file_metadata = {'request_id': req_id,
-                                 'scope': scope,
-                                 'name': name,
-                                 'activity': activity,
-                                 'request_type': RequestType.TRANSFER,
-                                 'src_type': transfer_src_type,
-                                 'dst_type': transfer_dst_type,
-                                 'src_rse': source_rse_name,
-                                 'dst_rse': dest_rse_name,
-                                 'src_rse_id': source_rse_id,
-                                 'dest_rse_id': dest_rse_id,
-                                 'filesize': bytes,
-                                 'md5': md5,
-                                 'adler32': adler32,
-                                 'verify_checksum': verify_checksum,
-                                 'source_globus_endpoint_id': source_globus_endpoint_id,
-                                 'dest_globus_endpoint_id': dest_globus_endpoint_id}
-
-                if previous_attempt_id:
-                    file_metadata['previous_attempt_id'] = previous_attempt_id
-
-                transfers[req_id] = {'request_id': req_id,
-                                     'schemes': __add_compatible_schemes(schemes=[dest_scheme], allowed_schemes=SUPPORTED_PROTOCOLS),
-                                     'account': account,
-                                     # 'src_urls': [source_url],
-                                     'sources': [(rse, source_url, source_rse_id, ranking, link_ranking)],
-                                     'dest_urls': [dest_url],
-                                     'src_spacetoken': None,
-                                     'dest_spacetoken': dest_spacetoken,
-                                     'overwrite': overwrite,
-                                     'bring_online': bring_online,
-                                     'copy_pin_lifetime': dict_attributes.get('lifetime', 172800),
-                                     'external_host': external_host,
-                                     'selection_strategy': 'auto',
-                                     'rule_id': rule_id,
-                                     'file_metadata': file_metadata,
-                                     'dest_scheme_priority': dest_scheme_priority}
-                if multihop:
-                    transfers[req_id]['multihop'] = True
-                    transfers[req_id]['initial_request_id'] = req_id
-                if strict_copy:
-                    transfers[req_id]['strict_copy'] = strict_copy
-                if use_ipv4:
-                    transfers[req_id]['use_ipv4'] = True
-                if archive_timeout and ctx.is_tape_rse(dest_rse_id):
-                    try:
-                        transfers[req_id]['archive_timeout'] = int(archive_timeout)
-                        logger(logging.DEBUG, 'Added archive timeout to transfer.')
-                    except ValueError:
-                        logger(logging.WARNING, 'Could not set archive_timeout for %s. Must be integer.', dest_url)
-                        pass
-            else:
-                # parse allow tape source expression, not finally version.
-                allow_tape_source = dict_attributes.get('allow_tape_source', None)
-
-                # No check yet if the previous one is a multihop or not.
-                # TODO : Check if the current  transfer is better than the previous one
-                if multihop:
-                    continue
-
-                current_source_is_tape = transfers[req_id]['bring_online']
-                new_source_is_tape = ctx.is_tape_rse(source_rse_id) or ctx.rse_attrs(source_rse_id).get('staging_required', False)
+                allowed_source_rses = [x['id'] for x in parsed_rses]
+
+        filtered_sources = rws.sources
+        # Only keep allowed sources
+        if allowed_source_rses is not None:
+            filtered_sources = filter(lambda s: s.rse.id in allowed_source_rses, filtered_sources)
+        filtered_sources = filter(lambda s: s.rse.name is not None, filtered_sources)
+        # Ignore blacklisted RSEs
+        filtered_sources = filter(lambda s: s.rse.id not in unavailable_read_rse_ids, filtered_sources)
+        # Ignore tape sources if they are not desired
+        filtered_sources = list(filtered_sources)
+        had_tape_sources = len(filtered_sources) > 0
+        if not rws.attributes.get("allow_tape_source", True):
+            filtered_sources = filter(lambda s: not s.rse.is_tape() and not s.rse.attributes.get('staging_required', False), filtered_sources)
+
+        filtered_sources = list(filtered_sources)
+        any_source_had_scheme_mismatch = False
+        candidate_paths = []
+        for source in filtered_sources:
+            # Call the get_hops function to create a list of RSEs used for the transfer
+            # In case the source_rse and the dest_rse are connected, the list contains only the destination RSE
+            # In case of non-connected, the list contains all the intermediary RSEs
+            try:
+                list_hops = get_hops(source.rse.id,
+                                     rws.dest_rse.id,
+                                     include_multihop=include_multihop,
+                                     multihop_rses=multihop_rses,
+                                     session=session)
+            except NoDistance:
+                logger(logging.WARNING, "Request %s: no link from %s to %s", rws.request_id, source.rse, rws.dest_rse)
+                continue
+            except RSEProtocolNotSupported:
+                any_source_had_scheme_mismatch = True
+                logger(logging.WARNING, "Request %s: no matching protocol between %s and %s", rws.request_id, source.rse, rws.dest_rse)
+                continue
 
-                if new_source_is_tape and not allow_tape_source:
-                    continue
+            try:
+                transfer_path = __prepare_transfer_definition(ctx, protocol_factory, rws=rws, source=source, transfertool=transfertool,
+                                                              retry_other_fts=retry_other_fts, list_hops=list_hops, logger=logger, session=session,
+                                                              bring_online=bring_online)
+                if transfer_path:
+                    candidate_paths.append(transfer_path)
+            except Exception:
+                logger(logging.CRITICAL, "Exception happened when trying to get transfer for request %s:" % rws.request_id, exc_info=True)
+                continue
 
-                if current_source_is_tape and not new_source_is_tape or \
-                        new_source_is_tape and not current_source_is_tape:
-                    # Tape and Disk sources must not be used at the same time.
-                    # Either keep existing sources unchanged, or substitute all existing source with the new source.
-
-                    # Find the best ranking among existing sources
-                    avail_top_ranking = None
-                    for founded_source in transfers[req_id]['sources']:
-                        if avail_top_ranking is None:
-                            avail_top_ranking = founded_source[3]
-                            continue
-                        if founded_source[3] is not None and founded_source[3] > avail_top_ranking:
-                            avail_top_ranking = founded_source[3]
-
-                    # If ranking of the new source is better. On equal ranking, prefer Disk over Tape.
-                    if avail_top_ranking is None or (ranking > avail_top_ranking) or (ranking >= avail_top_ranking and current_source_is_tape):
-                        transfers[req_id]['sources'] = []
-                        transfers[req_id]['bring_online'] = bring_online_local if new_source_is_tape else None
-                        transfers[req_id]['file_metadata']['src_type'] = 'TAPE' if new_source_is_tape else 'DISK'
-                        transfers[req_id]['file_metadata']['src_rse'] = rse
-                    else:
-                        continue
+        best_path = __pick_best_transfer(candidate_paths)
+        if best_path and len(best_path) == 1 and not best_path[0].src.rse.is_tape():
+            # Multiple single-hop DISK rses can be used together in "multi-source" transfers
+            #
+            # Try adding additional single-hop DISK rses sources to the transfer
+            additional_sources = [s for s in filtered_sources
+                                  if s.rse != best_path[0].src.rse and not s.rse.is_tape()]
+
+            for source, hop in __find_compatible_direct_sources(sources=additional_sources, scheme=best_path[0]['schemes'], dest_rse_id=rws.dest_rse.id, session=session):
+                best_path[0].sources.append(TransferSource(
+                    rse_data=source.rse,
+                    file_path=source.file_path,
+                    source_ranking=source.source_ranking,
+                    distance_ranking=hop['hop_distance'],
+                    scheme=hop['source_scheme'],
+                ))
+
+        if best_path:
+            transfer_path_for_request.append((rws, best_path))
+            reqs_no_source.remove(rws.request_id)
+        else:
+            # It can happen that some sources are skipped because they are TAPE, and others because
+            # of scheme mismatch. However, we can only have one state in the database. I picked to
+            # prioritize setting only_tape_source without any particular reason.
+            if had_tape_sources and not filtered_sources:
+                reqs_only_tape_source.add(rws.request_id)
+                reqs_no_source.remove(rws.request_id)
+            elif any_source_had_scheme_mismatch:
+                reqs_scheme_mismatch.add(rws.request_id)
+                reqs_no_source.remove(rws.request_id)
+
+    # Ensure each single-hop transfer has its own request id. Create intermediate replicas and requests if needed.
+    # Organize into the return format: {request_id: <transfer_definition_dict>}
+    transfers = {}
+    for rws, transfer_path in transfer_path_for_request:
+        # The last hop is the initial request
+        transfers[rws.request_id] = transfer_path[-1]
+        transfers[rws.request_id]['file_metadata']['request_id'] = rws.request_id
+        transfers[rws.request_id]['request_id'] = rws.request_id
 
-                if current_source_is_tape and new_source_is_tape:
-                    # multiple Tape source replicas are not allowed in FTS3.
-                    # Either keep the old source. Or substitute it with the new one.
-                    prev_is_multihop = transfers[req_id]['sources'][0][4] is None  # will be None if the previous transfer is multihop
-                    if ranking > transfers[req_id]['sources'][0][3]\
-                            or (ranking == transfers[req_id]['sources'][0][3] and (prev_is_multihop
-                                                                                   or link_ranking < transfers[req_id]['sources'][0][4])):
-                        transfers[req_id]['sources'] = []
-                        transfers[req_id]['bring_online'] = bring_online_local
-                        transfers[req_id]['file_metadata']['src_rse'] = rse
-                    else:
-                        continue
+        # If the transfer is a multihop, need to create the intermediate replicas, intermediate requests and the transfers
+        parent_request = None
+        parent_requests = []
+        for transfer in transfer_path[:-1]:
+            # hop = {'source_rse_id': source_rse_id, 'source_scheme': 'srm', 'source_scheme_priority': N, 'dest_rse_id': dest_rse_id, 'dest_scheme': 'srm', 'dest_scheme_priority': N}
+            source_rse = transfer.src.rse
+            dest_rse = transfer.dst.rse
+            dest_rse_vo = get_rse_vo(rse_id=dest_rse.id, session=session)
+
+            files = [{'scope': rws.scope,
+                      'name': rws.name,
+                      'bytes': rws.byte_count,
+                      'adler32': rws.adler32,
+                      'md5': rws.md5,
+                      'state': 'C'}]
+            try:
+                add_replicas(rse_id=dest_rse.id,
+                             files=files,
+                             account=InternalAccount('root', vo=dest_rse_vo),
+                             ignore_availability=False,
+                             dataset_meta=None,
+                             session=session)
+            except Exception as error:
+                logger(logging.ERROR, 'Problem adding replicas %s:%s on %s : %s', rws.scope, rws.name, dest_rse, str(error))
+
+            req_attributes = {'activity': rws.activity,
+                              'source_replica_expression': None,
+                              'lifetime': None,
+                              'ds_scope': None,
+                              'ds_name': None,
+                              'bytes': rws.byte_count,
+                              'md5': rws.md5,
+                              'adler32': rws.adler32,
+                              'priority': None,
+                              'allow_tape_source': True}
+            new_req = queue_requests(requests=[{'dest_rse_id': dest_rse.id,
+                                                'scope': rws.scope,
+                                                'name': rws.name,
+                                                'rule_id': '00000000000000000000000000000000',  # Dummy Rule ID used for multihop. TODO: Replace with actual rule_id once we can flag intermediate requests
+                                                'attributes': req_attributes,
+                                                'request_type': RequestType.TRANSFER,
+                                                'retry_count': rws.retry_count,
+                                                'account': InternalAccount('root', vo=dest_rse_vo),
+                                                'requested_at': datetime.datetime.now()}], session=session)
+            # If a request already exists, new_req will be an empty list.
+            if not new_req:
+                # Need to fail all the intermediate requests + the initial one and exit the multihop loop
+                logger(logging.WARNING, 'Multihop : A request already exists for the transfer between %s and %s. Will cancel all the parent requests', source_rse, dest_rse)
+                parent_requests.append(rws.request_id)
+                try:
+                    set_requests_state(request_ids=parent_requests, new_state=RequestState.FAILED, session=session)
+                except UnsupportedOperation:
+                    logger(logging.ERROR, 'Multihop : Cannot cancel all the parent requests : %s', str(parent_requests))
+
+                # Remove from the transfer dictionary all the requests
+                for cur_req_id in parent_requests:
+                    transfers.pop(cur_req_id, None)
+                break
 
-                # The transfer queued previously is a multihop, but this one is direct.
-                # Reset the sources, remove the multihop flag
-                if transfers[req_id].get('multihop', False):
-                    transfers[req_id].pop('multihop', None)
-                    transfers[req_id]['sources'] = []
+            new_req_id = new_req[0]['id']
+            parent_requests.append(new_req_id)
+            set_requests_state(request_ids=[new_req_id, ], new_state=RequestState.QUEUED, session=session)
+            logger(logging.DEBUG, 'New request created for the transfer between %s and %s : %s', source_rse, dest_rse, new_req_id)
 
-                transfers[req_id]['sources'].append((rse, source_url, source_rse_id, ranking, link_ranking))
-                # if one source has force IPv4, force IPv4 for the whole job
-                use_ipv4 = ctx.rse_attrs(source_rse_id).get('use_ipv4', False)
-                if use_ipv4:
-                    transfers[req_id]['use_ipv4'] = True
+            transfers[new_req_id] = transfer
+            transfers[new_req_id]['file_metadata']['request_id'] = new_req_id
+            transfers[new_req_id]['request_id'] = new_req_id
+            transfers[new_req_id]['initial_request_id'] = rws.request_id
+            transfers[new_req_id]['parent_request'] = parent_request
+            transfers[new_req_id]['account'] = InternalAccount('root')
 
-        except Exception:
-            logger(logging.CRITICAL, "Exception happened when trying to get transfer for request %s:" % (req_id), exc_info=True)
-            break
+            parent_request = new_req_id
+        if len(transfer_path) > 1:
+            # Add parent to the last hop
+            transfers[rws.request_id]['parent_request'] = parent_request
 
     # checking OIDC AuthN/Z support per destination and soucre RSEs;
     # assumes use of boolean 'oidc_support' RSE attribute
     for req_id in transfers:
         use_oidc = False
-        dest_rse_id = transfers[req_id]['file_metadata']['dest_rse_id']
-        if 'oidc_support' in ctx.rse_attrs(dest_rse_id):
-            use_oidc = ctx.rse_attrs(dest_rse_id)['oidc_support']
+        dest_rse = ctx.rse_data(transfers[req_id]['file_metadata']['dest_rse_id'])
+        if 'oidc_support' in dest_rse.attributes:
+            use_oidc = dest_rse.attributes['oidc_support']
         else:
             transfers[req_id]['use_oidc'] = use_oidc
             continue
         for source in transfers[req_id]['sources']:
-            source_rse_id = source[2]
-            if 'oidc_support' in ctx.rse_attrs(source_rse_id):
-                use_oidc = use_oidc and ctx.rse_attrs(source_rse_id)['oidc_support']
+            source_rse = ctx.rse_data(source[2])
+            if 'oidc_support' in source_rse.attributes:
+                use_oidc = use_oidc and source_rse.attributes['oidc_support']
             else:
                 use_oidc = False
             if not use_oidc:
@@ -1118,192 +1427,32 @@ def protocol(self, rse_id, scheme, operation):
         # OIDC token will be requested for the account of this tranfer
         transfers[req_id]['use_oidc'] = use_oidc
 
-    for req_id in copy.deepcopy(transfers):
-        # If the transfer is a multihop, need to create the intermediate replicas, intermediate requests and the transfers
-        if transfers[req_id].get('multihop', False):
-            parent_request = None
-            scope = transfers[req_id]['file_metadata']['scope']
-            name = transfers[req_id]['file_metadata']['name']
-            list_multihop, dict_attributes, retry_count = multi_hop_dict[req_id]
-            parent_requests = []
-
-            for hop in list_multihop:
-                # hop = {'source_rse_id': source_rse_id, 'source_scheme': 'srm', 'source_scheme_priority': N, 'dest_rse_id': dest_rse_id, 'dest_scheme': 'srm', 'dest_scheme_priority': N}
-                source_scheme = hop['source_scheme']
-                source_rse_id = hop['source_rse_id']
-                dest_rse_id = hop['dest_rse_id']
-                source_rse_name = ctx.rse_name(source_rse_id)
-                dest_rse_name = ctx.rse_name(dest_rse_id)
-                dest_rse_vo = get_rse_vo(rse_id=hop['dest_rse_id'], session=session)
-                transfer_src_type = "DISK"
-                transfer_dst_type = "DISK"
-                allow_tape_source = True
-                # Compute the source URL. We don't need to fill the rse_mapping and rse_attrs for the intermediate RSEs cause it has already been done before
-                source_protocol = ctx.protocol(source_rse_id, source_scheme, 'read')
-                source_url = list(source_protocol.lfns2pfns(lfns={'scope': scope.external, 'name': name, 'path': None}).values())[0]
-
-                if transfers[req_id]['file_metadata']['dest_rse_id'] != hop['dest_rse_id']:
-                    files = [{'scope': scope,
-                              'name': name,
-                              'bytes': transfers[req_id]['file_metadata']['filesize'],
-                              'adler32': transfers[req_id]['file_metadata']['adler32'],
-                              'md5': transfers[req_id]['file_metadata']['md5'],
-                              'state': 'C'}]
-                    try:
-                        add_replicas(rse_id=hop['dest_rse_id'],
-                                     files=files,
-                                     account=InternalAccount('root', vo=dest_rse_vo),
-                                     ignore_availability=False,
-                                     dataset_meta=None,
-                                     session=session)
-                    except Exception as error:
-                        logger(logging.ERROR, 'Problem adding replicas %s:%s on %s : %s', scope, name, dest_rse_name, str(error))
-
-                    req_attributes = {'activity': transfers[req_id]['file_metadata']['activity'],
-                                      'source_replica_expression': None,
-                                      'lifetime': None,
-                                      'ds_scope': None,
-                                      'ds_name': None,
-                                      'bytes': transfers[req_id]['file_metadata']['filesize'],
-                                      'md5': transfers[req_id]['file_metadata']['md5'],
-                                      'adler32': transfers[req_id]['file_metadata']['adler32'],
-                                      'priority': None,
-                                      'allow_tape_source': True}
-                    new_req = queue_requests(requests=[{'dest_rse_id': dest_rse_id,
-                                                        'scope': scope,
-                                                        'name': name,
-                                                        'rule_id': '00000000000000000000000000000000',  # Dummy Rule ID used for multihop. TODO: Replace with actual rule_id once we can flag intermediate requests
-                                                        'attributes': req_attributes,
-                                                        'request_type': RequestType.TRANSFER,
-                                                        'retry_count': retry_count,
-                                                        'account': InternalAccount('root', vo=dest_rse_vo),
-                                                        'requested_at': datetime.datetime.now()}], session=session)
-                    # If a request already exists, new_req will be an empty list.
-                    if not new_req:
-                        # Need to fail all the intermediate requests + the initial one and exit the multihop loop
-                        logger(logging.WARNING, 'Multihop : A request already exists for the transfer between %s and %s. Will cancel all the parent requests', source_rse_name, dest_rse_name)
-                        parent_requests.append(req_id)
-                        try:
-                            set_requests_state(request_ids=parent_requests, new_state=RequestState.FAILED, session=session)
-                        except UnsupportedOperation:
-                            logger(logging.ERROR, 'Multihop : Cannot cancel all the parent requests : %s', str(parent_requests))
-
-                        # Remove from the transfer dictionary all the requests
-                        for cur_req_id in parent_requests:
-                            transfers.pop(cur_req_id, None)
-                        break
-                    new_req_id = new_req[0]['id']
-                    parent_requests.append(new_req_id)
-                    set_requests_state(request_ids=[new_req_id, ], new_state=RequestState.QUEUED, session=session)
-                    logger(logging.DEBUG, 'New request created for the transfer between %s and %s : %s', source_rse_name, dest_rse_name, new_req_id)
-
-                    # Here we will compute the destination URL
-                    # Get destination protocol
-                    dest_rse_id = hop['dest_rse_id']
-                    dest_scheme = hop['dest_scheme']
-                    dest_protocol = ctx.protocol(dest_rse_id, dest_scheme, 'write')
-
-                    # Get dest space token
-                    dest_spacetoken = None
-                    if dest_protocol.attributes and 'extended_attributes' in dest_protocol.attributes and \
-                            dest_protocol.attributes['extended_attributes'] and 'space_token' in dest_protocol.attributes['extended_attributes']:
-                        dest_spacetoken = dest_protocol.attributes['extended_attributes']['space_token']
-
-                    # Compute the destination url
-                    dest_url = __build_dest_url(scope=scope, name=name,
-                                                protocol=dest_protocol,
-                                                dest_rse_attrs=ctx.rse_attrs(dest_rse_id),
-                                                dest_is_deterministic=ctx.rse_info(dest_rse_id)['deterministic'],
-                                                dest_is_tape=ctx.is_tape_rse(dest_rse_id),
-                                                dict_attributes=dict_attributes,
-                                                retry_count=retry_count,
-                                                activity=activity)
-
-                    # Extend the metadata dictionary with request attributes
-                    overwrite, bring_online = True, None
-                    if ctx.is_tape_rse(source_rse_id):
-                        bring_online = bring_online_local
-                        transfer_src_type = "TAPE"
-                        if not allow_tape_source:
-                            if req_id not in reqs_only_tape_source:
-                                reqs_only_tape_source.append(req_id)
-                            if req_id in reqs_no_source:
-                                reqs_no_source.remove(req_id)
-                            continue
-                    if ctx.is_tape_rse(dest_rse_id):
-                        overwrite = False
-                        transfer_dst_type = "TAPE"
-
-                    file_metadata = {'request_id': new_req_id,
-                                     'scope': scope,
-                                     'name': name,
-                                     'activity': transfers[req_id]['file_metadata']['activity'],
-                                     'request_type': RequestType.TRANSFER,
-                                     'src_type': transfer_src_type,
-                                     'dst_type': transfer_dst_type,
-                                     'src_rse': source_rse_name,
-                                     'dst_rse': dest_rse_name,
-                                     'src_rse_id': source_rse_id,
-                                     'dest_rse_id': dest_rse_id,
-                                     'filesize': transfers[req_id]['file_metadata']['filesize'],
-                                     'md5': transfers[req_id]['file_metadata']['md5'],
-                                     'adler32': transfers[req_id]['file_metadata']['adler32'],
-                                     'verify_checksum': transfers[req_id]['file_metadata']['verify_checksum'],
-                                     'source_globus_endpoint_id': transfers[req_id]['file_metadata']['source_globus_endpoint_id'],
-                                     'dest_globus_endpoint_id': transfers[req_id]['file_metadata']['dest_globus_endpoint_id']}
-                    transfers[new_req_id] = {'request_id': new_req_id,
-                                             'initial_request_id': req_id,
-                                             'parent_request': parent_request,
-                                             'account': InternalAccount('root'),
-                                             'schemes': __add_compatible_schemes(schemes=[dest_scheme], allowed_schemes=SUPPORTED_PROTOCOLS),
-                                             # 'src_urls': [source_url],
-                                             'sources': [(source_rse_name, source_url, source_rse_id, 0, 0)],
-                                             'dest_urls': [dest_url],
-                                             'src_spacetoken': None,
-                                             'dest_spacetoken': dest_spacetoken,
-                                             'overwrite': transfers[req_id]['overwrite'],
-                                             'bring_online': bring_online,
-                                             'copy_pin_lifetime': transfers[req_id]['copy_pin_lifetime'],
-                                             'external_host': transfers[req_id]['external_host'],
-                                             'selection_strategy': 'auto',
-                                             'rule_id': transfers[req_id]['rule_id'],
-                                             'multihop': True,
-                                             'file_metadata': file_metadata}
-                    parent_request = new_req_id
-
-                else:
-                    # For the last hop, we just need to correct the source
-                    transfers[req_id]['parent_request'] = parent_request
-                    transfers[req_id]['file_metadata']['src_rse_id'] = source_rse_id
-                    transfers[req_id]['file_metadata']['src_rse'] = source_rse_name
-                    # We make the assumption that the hop is never made through TAPE
-                    transfers[req_id]['file_metadata']['src_type'] = 'DISK'
-                    transfers[req_id]['sources'] = [(source_rse_name, source_url, source_rse_id, 0, 0)]
-                    transfers[req_id]['bring_online'] = bring_online
-        if req_id in reqs_no_source:
-            reqs_no_source.remove(req_id)
-        if req_id in reqs_only_tape_source:
-            reqs_only_tape_source.remove(req_id)
-        if req_id in reqs_scheme_mismatch:
-            reqs_scheme_mismatch.remove(req_id)
-
     return transfers, reqs_no_source, reqs_scheme_mismatch, reqs_only_tape_source
 
 
 @read_session
-def __list_transfer_requests_and_source_replicas(total_workers=0, worker_number=0, limit=None, activity=None,
-                                                 older_than=None, rses=None, request_state=None, transfertool=None, session=None) -> "List[Tuple]":
+def __list_transfer_requests_and_source_replicas(
+    total_workers=0,
+    worker_number=0,
+    limit=None,
+    activity=None,
+    older_than=None,
+    rses=None,
+    request_state=None,
+    transfertool=None,
+    session=None,
+) -> "List[RequestWithSources]":
     """
     List requests with source replicas
-    :param total_workers:     Number of total workers.
-    :param worker_number:     Id of the executing worker.
+    :param total_workers:    Number of total workers.
+    :param worker_number:    Id of the executing worker.
     :param limit:            Integer of requests to retrieve.
     :param activity:         Activity to be selected.
     :param older_than:       Only select requests older than this DateTime.
     :param rses:             List of rse_id to select requests.
     :param transfertool:     The transfer tool as specified in rucio.cfg.
     :param session:          Database session to use.
-    :returns:                List.
+    :returns:                List of RequestWithSources objects.
     """
 
     if request_state is None:
@@ -1363,13 +1512,10 @@ def __list_transfer_requests_and_source_replicas(total_workers=0, worker_number=
                           sub_requests.c.previous_attempt_id,
                           sub_requests.c.dest_rse_id,
                           sub_requests.c.account,
+                          sub_requests.c.retry_count,
                           models.RSEFileAssociation.rse_id,
                           models.RSE.rse,
-                          models.RSE.deterministic,
-                          models.RSE.rse_type,
                           models.RSEFileAssociation.path,
-                          sub_requests.c.retry_count,
-                          models.Source.url,
                           models.Source.ranking.label("source_ranking"),
                           models.Distance.ranking.label("distance_ranking")) \
         .order_by(sub_requests.c.created_at) \
@@ -1395,14 +1541,25 @@ def __list_transfer_requests_and_source_replicas(total_workers=0, worker_number=
             .filter(models.RSEAttrAssociation.key == 'transfertool',
                     models.RSEAttrAssociation.value.like('%' + transfertool + '%'))
 
-    if rses:
-        result = []
-        for item in query.all():
-            dest_rse_id = item[10]
-            if dest_rse_id in rses:
-                result.append(item)
-        return result
-    return query.all()
+    requests_by_id = {}
+    for (request_id, rule_id, scope, name, md5, adler32, byte_count, activity, attributes, previous_attempt_id, dest_rse_id, account, retry_count,
+         source_rse_id, source_rse_name, file_path, source_ranking, distance_ranking) in query:
+
+        # rses (of unknown length) should be a temporary table to check against instead of this special case
+        if rses and dest_rse_id not in rses:
+            continue
+
+        request = requests_by_id.get(request_id)
+        if not request:
+            request = RequestWithSources(id=request_id, rule_id=rule_id, scope=scope, name=name, md5=md5, adler32=adler32, byte_count=byte_count,
+                                         activity=activity, attributes=attributes, previous_attempt_id=previous_attempt_id,
+                                         dest_rse_data=RseData(id=dest_rse_id), account=account, retry_count=retry_count)
+            requests_by_id[request_id] = request
+
+        if source_rse_id is not None:
+            request.sources.append(TransferSource(rse_data=RseData(id=source_rse_id, name=source_rse_name), file_path=file_path,
+                                                  source_ranking=source_ranking, distance_ranking=distance_ranking))
+    return list(requests_by_id.values())
 
 
 @transactional_session
@@ -1434,7 +1591,7 @@ def __get_unavailable_rse_ids(operation, session=None, logger=logging.log):
     """
 
     if operation not in ['read', 'write', 'delete']:
-        logger(logging.ERROR, "Wrong operation specified : %s" % (operation))
+        logger(logging.ERROR, "Wrong operation specified : %s" % operation)
         return []
     key = 'unavailable_%s_rse_ids' % operation
     result = REGION_SHORT.get(key)
@@ -1447,7 +1604,7 @@ def __get_unavailable_rse_ids(operation, session=None, logger=logging.log):
             REGION_SHORT.set(key, unavailable_rse_ids)
             return unavailable_rse_ids
         except Exception:
-            logger(logging.ERROR, "Failed to refresh unavailable %s rses, error" % (operation), exc_info=True)
+            logger(logging.ERROR, "Failed to refresh unavailable %s rses, error" % operation, exc_info=True)
             return []
     return result
 
diff --git a/lib/rucio/daemons/conveyor/submitter.py b/lib/rucio/daemons/conveyor/submitter.py
--- a/lib/rucio/daemons/conveyor/submitter.py
+++ b/lib/rucio/daemons/conveyor/submitter.py
@@ -41,7 +41,6 @@
 
 import logging
 import os
-import random
 import socket
 import threading
 import time
@@ -354,74 +353,5 @@ def __get_transfers(total_workers=0, worker_number=0, failover_schemes=None, lim
     request_core.set_requests_state_if_possible(reqs_scheme_mismatch, RequestState.MISMATCH_SCHEME, logger=logger)
 
     for request_id in transfers:
-        sources = transfers[request_id]['sources']
-        sources = __sort_ranking(sources, logger=logger)
-        if len(sources) > max_sources:
-            sources = sources[:max_sources]
-        transfers[request_id]['sources'] = sources
-
-        # remove link_ranking in the final sources
-        sources = transfers[request_id]['sources']
-        transfers[request_id]['sources'] = []
-        for rse, source_url, source_rse_id, ranking, link_ranking in sources:
-            transfers[request_id]['sources'].append((rse, source_url, source_rse_id, ranking))
-
-        transfers[request_id]['file_metadata']['src_rse'] = sources[0][0]
-        transfers[request_id]['file_metadata']['src_rse_id'] = sources[0][2]
         logger(logging.DEBUG, "Transfer for request(%s): %s", request_id, transfers[request_id])
     return transfers
-
-
-def __sort_link_ranking(sources):
-    """
-    Sort a list of sources based on link ranking
-
-    :param sources:  List of sources
-    :return:         Sorted list
-    """
-
-    rank_sources = {}
-    ret_sources = []
-    for source in sources:
-        rse, source_url, source_rse_id, ranking, link_ranking = source
-        if link_ranking not in rank_sources:
-            rank_sources[link_ranking] = []
-        rank_sources[link_ranking].append(source)
-    rank_keys = list(rank_sources.keys())
-    rank_keys.sort()
-    for rank_key in rank_keys:
-        sources_list = rank_sources[rank_key]
-        random.shuffle(sources_list)
-        ret_sources = ret_sources + sources_list
-    return ret_sources
-
-
-def __sort_ranking(sources, logger=logging.log):
-    """
-    Sort a list of sources based on ranking
-
-    :param sources:  List of sources
-    :param logger:   Optional decorated logger that can be passed from the calling daemons or servers.
-    :return:         Sorted list
-    """
-
-    logger(logging.DEBUG, "Sources before sorting: %s", str(sources))
-    rank_sources = {}
-    ret_sources = []
-    for source in sources:
-        # ranking is from sources table, is the retry times
-        # link_ranking is from distances table, is the link rank.
-        # link_ranking should not be None(None means no link, the source will not be used).
-        rse, source_url, source_rse_id, ranking, link_ranking = source
-        if ranking is None:
-            ranking = 0
-        if ranking not in rank_sources:
-            rank_sources[ranking] = []
-        rank_sources[ranking].append(source)
-    rank_keys = list(rank_sources.keys())
-    rank_keys.sort(reverse=True)
-    for rank_key in rank_keys:
-        sources_list = __sort_link_ranking(rank_sources[rank_key])
-        ret_sources = ret_sources + sources_list
-    logger(logging.DEBUG, "Sources after sorting: %s", str(ret_sources))
-    return ret_sources


Test patch:
diff --git a/lib/rucio/tests/test_preparer.py b/lib/rucio/tests/test_preparer.py
--- a/lib/rucio/tests/test_preparer.py
+++ b/lib/rucio/tests/test_preparer.py
@@ -26,8 +26,7 @@
 from rucio.core.did import add_did, delete_dids
 from rucio.core.distance import get_distances, add_distance
 from rucio.core.replica import add_replicas, delete_replicas
-from rucio.core.request import sort_requests_minimum_distance, request_id_col, distance_col, dest_rse_id_col, \
-    source_rse_id_col, extra_transfertool_col, get_transfertool_filter, get_supported_transfertools
+from rucio.core.request import sort_requests_minimum_distance, get_transfertool_filter, get_supported_transfertools
 from rucio.core.rse import set_rse_transfer_limits, add_rse, del_rse, add_rse_attribute
 from rucio.core.transfer import __list_transfer_requests_and_source_replicas
 from rucio.daemons.conveyor import preparer
@@ -209,7 +208,7 @@ def test_listing_preparing_transfers(db_session, mock_request):
     req_sources = __list_transfer_requests_and_source_replicas(request_state=RequestState.PREPARING, session=db_session)
 
     assert len(req_sources) != 0
-    found_requests = list(filter(lambda req: req[request_id_col] == mock_request.id, req_sources))
+    found_requests = list(filter(lambda rws: rws.request_id == mock_request.id, req_sources))
     assert len(found_requests) == 1
 
 
@@ -272,9 +271,9 @@ def test_preparer_for_request_without_matching_transfertool_source(db_session, s
 
 
 @pytest.mark.xfail(reason='fails when run in parallel')
-def test_two_sources_one_destination(db_session, vo, file, source_rse, dest_rse, mock_request):
+def test_two_sources_one_destination(db_session, vo, file, mock_request):
     def setup(rse):
-        add_distance(rse.rse_id, dest_rse['id'], ranking=2, session=rse.db_session)
+        add_distance(rse.rse_id, mock_request.dest_rse_id, ranking=2, session=rse.db_session)
         add_replicas(rse_id=rse.rse_id, files=[file], account=mock_request.account, session=rse.db_session)
 
     with GeneratedRSE(vo=vo, db_session=db_session, setup_func=setup) as source2_rse:
@@ -284,7 +283,7 @@ def setup(rse):
                 dest_rse_id=mock_request.dest_rse_id,
                 session=db_session,
             )
-            for src_rse in (source_rse['id'], source2_rse.rse_id)
+            for src_rse in (mock_request.source_rse_id, source2_rse.rse_id)
         )
 
         assert src1_distance and len(src1_distance) == 1 and src1_distance[0]['ranking'] == 5
@@ -304,26 +303,24 @@ def setup(rse):
 
 
 def test_sort_requests_minimum_distance():
-    request_tuples = [[]] * 3
-    for i in range(3):
-        request_tuples[i] = [0] * 21
-        request_tuples[i][request_id_col] = i
-        request_tuples[i][distance_col] = 3 - i
-
-    result = sort_requests_minimum_distance(request_tuples)
-    assert next(result)[request_id_col] == 2
-    assert next(result)[request_id_col] == 1
-    assert next(result)[request_id_col] == 0
+    request_dicts = [{}, {}, {}]
+    for i in range(len(request_dicts)):
+        request_dicts[i]['request_id'] = i
+        request_dicts[i]['distance_ranking'] = 3 - i
+
+    result = sort_requests_minimum_distance(request_dicts)
+    assert next(result)['request_id'] == 2
+    assert next(result)['request_id'] == 1
+    assert next(result)['request_id'] == 0
     pytest.raises(StopIteration, next, result)
 
 
 def test_filter_requests_for_transfertools():
-    request_tuples = [[]] * 3
-    for i in range(3):
-        request_tuples[i] = [0] * 21
-        request_tuples[i][request_id_col] = 0
-        request_tuples[i][dest_rse_id_col] = 'rse1'
-        request_tuples[i][source_rse_id_col] = f'rse{2 + i}'
+    request_dicts = [{}, {}, {}]
+    for i in range(len(request_dicts)):
+        request_dicts[i]['request_id'] = 0  # same request for all
+        request_dicts[i]['dest_rse_id'] = 'rse1'
+        request_dicts[i]['src_rse_id'] = f'rse{2 + i}'
 
     def get_transfertools(rse_id: str) -> "Set[str]":
         assert rse_id
@@ -339,17 +336,21 @@ def get_transfertools(rse_id: str) -> "Set[str]":
             raise AssertionError('rse_id out of range')
 
     transfertool_filter = get_transfertool_filter(get_transfertools=get_transfertools)
-    result = list(transfertool_filter(request_tuples))
+    result = list(transfertool_filter(request_dicts))
     print(result)
 
     assert len(result) == 2
-    result.sort(key=lambda t: t[source_rse_id_col])
-    assert result[0][source_rse_id_col] == 'rse3'
-    assert len(result[0]) == 22
-    assert result[0][extra_transfertool_col] == 'globus'
-    assert result[1][source_rse_id_col] == 'rse4'
-    assert len(result[1]) == 22
-    assert result[1][extra_transfertool_col] == 'globus'
+    result.sort(key=lambda rws_dict: rws_dict['src_rse_id'])
+    assert result[0]['request_id'] == 0
+    assert result[0]['src_rse_id'] == 'rse3'
+    assert result[0]['dest_rse_id'] == 'rse1'
+    assert 'transfertool' in result[0]
+    assert result[0]['transfertool'] == 'globus'
+    assert result[1]['request_id'] == 0
+    assert result[1]['src_rse_id'] == 'rse4'
+    assert result[1]['dest_rse_id'] == 'rse1'
+    assert 'transfertool' in result[1]
+    assert result[1]['transfertool'] == 'globus'
 
 
 def test_get_supported_transfertools_default(vo, db_session):
diff --git a/lib/rucio/tests/test_transfer.py b/lib/rucio/tests/test_transfer.py
--- a/lib/rucio/tests/test_transfer.py
+++ b/lib/rucio/tests/test_transfer.py
@@ -19,11 +19,12 @@
 import pytest
 
 from rucio.common.exception import NoDistance
-from rucio.core.distance import add_distance, update_distances
+from rucio.core.distance import add_distance
 from rucio.core.replica import add_replicas
 from rucio.core.transfer import get_hops, get_transfer_requests_and_source_replicas
 from rucio.core import rule as rule_core
 from rucio.core import request as request_core
+from rucio.core import rse as rse_core
 from rucio.db.sqla import models
 from rucio.db.sqla.constants import RSEType
 from rucio.db.sqla.session import transactional_session
@@ -154,6 +155,10 @@ def test_disk_vs_tape_priority(rse_factory, root_account, mock_scope):
     dst_rse_name, dst_rse_id = rse_factory.make_posix_rse()
     source_rses = [tape1_rse_id, tape2_rse_id, disk1_rse_id, disk2_rse_id]
     all_rses = source_rses + [dst_rse_id]
+    add_distance(disk1_rse_id, dst_rse_id, ranking=15)
+    add_distance(disk2_rse_id, dst_rse_id, ranking=10)
+    add_distance(tape1_rse_id, dst_rse_id, ranking=15)
+    add_distance(tape2_rse_id, dst_rse_id, ranking=10)
 
     # add same file to all source RSEs
     file = {'scope': mock_scope, 'name': 'lfn.' + generate_uuid(), 'type': 'FILE', 'bytes': 1, 'adler32': 'beefdead'}
@@ -179,10 +184,6 @@ def __fake_source_ranking(source_rse_id, new_ranking, session=None):
                           is_using=False). \
                 save(session=session, flush=False)
 
-    # Init all distances to the same value
-    for rse_id in source_rses:
-        add_distance(rse_id, dst_rse_id, ranking=10)
-
     # On equal priority and distance, disk should be preferred over tape. Both disk sources will be returned
     transfers, _reqs_no_source, _reqs_scheme_mismatch, _reqs_only_tape_source = get_transfer_requests_and_source_replicas(rses=all_rses)
     assert len(transfers) == 1
@@ -201,7 +202,6 @@ def __fake_source_ranking(source_rse_id, new_ranking, session=None):
     assert transfer['sources'][0][0] in (tape1_rse_name, tape2_rse_name)
 
     # On equal source ranking, but different distance; the smaller distance is preferred
-    update_distances(tape1_rse_id, dst_rse_id, parameters={'ranking': 15})
     transfers, _reqs_no_source, _reqs_scheme_mismatch, _reqs_only_tape_source = get_transfer_requests_and_source_replicas(rses=all_rses)
     assert len(transfers) == 1
     transfer = next(iter(transfers.values()))
@@ -215,3 +215,96 @@ def __fake_source_ranking(source_rse_id, new_ranking, session=None):
     transfer = next(iter(transfers.values()))
     assert len(transfer['sources']) == 1
     assert transfer['sources'][0][0] == tape1_rse_name
+
+
+@pytest.mark.parametrize("core_config_mock", [{"table_content": [
+    ('transfers', 'use_multihop', True)
+]}], indirect=True)
+@pytest.mark.parametrize("caches_mock", [{"caches_to_mock": [
+    'rucio.core.rse_expression_parser',  # The list of multihop RSEs is retrieved by an expression
+    'rucio.core.config',
+]}], indirect=True)
+def test_multihop_requests_created(rse_factory, did_factory, root_account, core_config_mock, caches_mock):
+    """
+    Ensure that multihop transfers are handled and intermediate request correctly created
+    """
+    rs0_name, src_rse_id = rse_factory.make_posix_rse()
+    _, intermediate_rse_id = rse_factory.make_posix_rse()
+    dst_rse_name, dst_rse_id = rse_factory.make_posix_rse()
+    rse_core.add_rse_attribute(intermediate_rse_id, 'available_for_multihop', True)
+
+    add_distance(src_rse_id, intermediate_rse_id, ranking=10)
+    add_distance(intermediate_rse_id, dst_rse_id, ranking=10)
+
+    did = did_factory.upload_test_file(rs0_name)
+    rule_core.add_rule(dids=[did], account=root_account, copies=1, rse_expression=dst_rse_name, grouping='ALL', weight=None, lifetime=None, locked=False, subscription_id=None)
+
+    transfers, _reqs_no_source, _reqs_scheme_mismatch, _reqs_only_tape_source = get_transfer_requests_and_source_replicas(rses=rse_factory.created_rses)
+    assert len(transfers) == 2
+    # ensure that one transfer is the parent of the other
+    assert any(t for t in transfers.values() if t.get('parent_request') in transfers)
+    # the intermediate request was correctly created
+    assert request_core.get_request_by_did(rse_id=intermediate_rse_id, **did)
+
+
+@pytest.mark.parametrize("core_config_mock", [{"table_content": [
+    ('transfers', 'use_multihop', True)
+]}], indirect=True)
+@pytest.mark.parametrize("caches_mock", [{"caches_to_mock": [
+    'rucio.core.rse_expression_parser',  # The list of multihop RSEs is retrieved by an expression
+    'rucio.core.config',
+]}], indirect=True)
+def test_singlehop_vs_multihop_priority(rse_factory, root_account, mock_scope, core_config_mock, caches_mock):
+    """
+    On small distance difference, singlehop is prioritized over multihop
+    due to HOP_PENALTY. On big difference, multihop is prioritized
+    """
+    # +------+    +------+
+    # |      | 10 |      |
+    # | RSE0 +--->| RSE1 |
+    # |      |    |      +-+ 10
+    # +------+    +------+ |  +------+       +------+
+    #                      +->|      |  200  |      |
+    # +------+                | RSE3 |<------| RSE4 |
+    # |      |   30      +--->|      |       |      |
+    # | RSE2 +-----------+    +------+       +------+
+    # |      |
+    # +------+
+    _, rse0_id = rse_factory.make_posix_rse()
+    _, rse1_id = rse_factory.make_posix_rse()
+    _, rse2_id = rse_factory.make_posix_rse()
+    rse3_name, rse3_id = rse_factory.make_posix_rse()
+    _, rse4_id = rse_factory.make_posix_rse()
+
+    add_distance(rse0_id, rse1_id, ranking=10)
+    add_distance(rse1_id, rse3_id, ranking=10)
+    add_distance(rse2_id, rse3_id, ranking=30)
+    add_distance(rse4_id, rse3_id, ranking=200)
+    rse_core.add_rse_attribute(rse1_id, 'available_for_multihop', True)
+
+    # add same file to two source RSEs
+    file = {'scope': mock_scope, 'name': 'lfn.' + generate_uuid(), 'type': 'FILE', 'bytes': 1, 'adler32': 'beefdead'}
+    did = {'scope': file['scope'], 'name': file['name']}
+    for rse_id in [rse0_id, rse2_id]:
+        add_replicas(rse_id=rse_id, files=[file], account=root_account)
+
+    rule_core.add_rule(dids=[did], account=root_account, copies=1, rse_expression=rse3_name, grouping='ALL', weight=None, lifetime=None, locked=False, subscription_id=None)
+
+    # The singlehop must be prioritized
+    transfers, _reqs_no_source, _reqs_scheme_mismatch, _reqs_only_tape_source = get_transfer_requests_and_source_replicas(rses=rse_factory.created_rses)
+    assert len(transfers) == 1
+    transfer = next(iter(transfers.values()))
+    assert transfer['file_metadata']['src_rse_id'] == rse2_id
+    assert transfer['file_metadata']['dest_rse_id'] == rse3_id
+
+    # add same file to two source RSEs
+    file = {'scope': mock_scope, 'name': 'lfn.' + generate_uuid(), 'type': 'FILE', 'bytes': 1, 'adler32': 'beefdead'}
+    did = {'scope': file['scope'], 'name': file['name']}
+    for rse_id in [rse0_id, rse4_id]:
+        add_replicas(rse_id=rse_id, files=[file], account=root_account)
+
+    rule_core.add_rule(dids=[did], account=root_account, copies=1, rse_expression=rse3_name, grouping='ALL', weight=None, lifetime=None, locked=False, subscription_id=None)
+
+    # The multihop must be prioritized
+    transfers, _reqs_no_source, _reqs_scheme_mismatch, _reqs_only_tape_source = get_transfer_requests_and_source_replicas(rses=rse_factory.created_rses)
+    assert len([t for t in transfers.values() if t['file_metadata']['name'] == did['name']]) == 2