Repository: rucio/rucio

Issue:
Possible regression in `rucio download --nrandom`
Motivation
----------
In #4626 the `--nrandom` option of rucio download was changed to be able to handle very large datasets as well, by doing a smart random selection on the server side. It seems that in a recent change (Possibly 1.28?) this does not work anymore though.

```
rucio download --nrandom 1 mc21_13p6TeV.601229.PhPy8EG_A14_ttbar_hdamp258p75_SingleLep.simul.HITS.e8357_e7400_s3775
2022-03-30 12:15:53,836	INFO	Processing 1 item(s) for input
2022-03-30 12:15:54,029	INFO	Selecting 1 random replicas from DID(s): ['mc21_13p6TeV:mc21_13p6TeV.601229.PhPy8EG_A14_ttbar_hdamp258p75_SingleLep.simul.HITS.e8357_e7400_s3775']
2022-03-30 12:20:54,036	ERROR	ConnectionError: ('Connection aborted.', BadStatusLine("''",))
```

Modification
------------
Investigate the issue and fix. 


Discussion:

Resolving patch:
diff --git a/lib/rucio/core/did.py b/lib/rucio/core/did.py
--- a/lib/rucio/core/did.py
+++ b/lib/rucio/core/did.py
@@ -46,6 +46,7 @@
 if TYPE_CHECKING:
     from typing import Any, Dict, Tuple, Optional, Sequence, Callable
     from sqlalchemy.orm import Session
+    from sqlalchemy.schema import Table
     from rucio.common.types import InternalAccount, InternalScope
 
     LoggerFunction = Callable[..., Any]
@@ -2117,38 +2118,167 @@ def list_all_parent_dids(scope, name, session=None):
             yield {'scope': pdid['scope'], 'name': pdid['name'], 'type': pdid['type']}
 
 
-@transactional_session
-def list_child_datasets(scope, name, session=None):
+def list_child_dids_stmt(
+        input_dids_table: "Table",
+        did_type: DIDType,
+        including_input_dids: bool = False,
+):
     """
-    List all child datasets of a container.
+    Build and returns a query which recursively lists children dids of type `did_type`
+    for the dids given as input in a scope/name (temporary) table.
 
-    :param scope:     The scope.
-    :param name:      The name.
-    :param session:   The database session
-    :returns:         List of dids
-    :rtype:           Generator
+    did_type defines the desired type of DIDs in the result. If set to DIDType.Dataset,
+    will only resolve containers and return datasets. If set to DIDType.File, will
+    also resolve the datasets and return files.
     """
+    if did_type == DIDType.DATASET:
+        dids_to_resolve = [DIDType.CONTAINER]
+    else:
+        dids_to_resolve = [DIDType.CONTAINER, DIDType.DATASET]
+
+    # Uses a recursive SQL CTE (Common Table Expressions)
+    initial_set = select(
+        models.DataIdentifierAssociation.child_scope,
+        models.DataIdentifierAssociation.child_name,
+        models.DataIdentifierAssociation.child_type,
+    ).join_from(
+        input_dids_table,
+        models.DataIdentifierAssociation,
+        and_(
+            models.DataIdentifierAssociation.scope == input_dids_table.scope,
+            models.DataIdentifierAssociation.name == input_dids_table.name,
+            models.DataIdentifierAssociation.did_type.in_(dids_to_resolve),
+        ),
+    ).cte(
+        recursive=True,
+    )
+    # Oracle doesn't support union() in recursive CTEs, so use UNION ALL
+    # and a "distinct" filter later
+    child_datasets_cte = initial_set.union_all(
+        select(
+            models.DataIdentifierAssociation.child_scope,
+            models.DataIdentifierAssociation.child_name,
+            models.DataIdentifierAssociation.child_type,
+        ).where(
+            models.DataIdentifierAssociation.scope == initial_set.c.child_scope,
+            models.DataIdentifierAssociation.name == initial_set.c.child_name,
+            models.DataIdentifierAssociation.did_type.in_(dids_to_resolve),
+        )
+    )
 
-    result = []
     stmt = select(
+        child_datasets_cte.c.child_scope,
+        child_datasets_cte.c.child_name,
+    ).distinct(
+    ).where(
+        child_datasets_cte.c.child_type == did_type,
+    )
+
+    if including_input_dids:
+        # Also include into the result the DIDs of the desired type present
+        # in the input list.
+        stmt = stmt.union(
+            select(
+                input_dids_table.scope,
+                input_dids_table.name,
+            ).join_from(
+                input_dids_table,
+                models.DataIdentifier,
+                and_(models.DataIdentifier.scope == input_dids_table.scope,
+                     models.DataIdentifier.name == input_dids_table.name,
+                     models.DataIdentifier.did_type == did_type),
+            )
+        )
+    return stmt
+
+
+def list_one_did_childs_stmt(
+        scope: "InternalScope",
+        name: str,
+        did_type: DIDType,
+        including_input_dids: bool = False,
+):
+    """
+    Returns the sqlalchemy query for recursively fetching the child dids of type
+    'did_type' for the input did.
+
+    did_type defines the desired type of DIDs in the result. If set to DIDType.Dataset,
+    will only resolve containers and return datasets. If set to DIDType.File, will
+    also resolve the datasets and return files.
+    """
+    if did_type == DIDType.DATASET:
+        dids_to_resolve = [DIDType.CONTAINER]
+    else:
+        dids_to_resolve = [DIDType.CONTAINER, DIDType.DATASET]
+
+    # Uses a recursive SQL CTE (Common Table Expressions)
+    initial_set = select(
         models.DataIdentifierAssociation.child_scope,
         models.DataIdentifierAssociation.child_name,
-        models.DataIdentifierAssociation.child_type
+        models.DataIdentifierAssociation.child_type,
     ).where(
         models.DataIdentifierAssociation.scope == scope,
         models.DataIdentifierAssociation.name == name,
-        models.DataIdentifierAssociation.child_type != DIDType.FILE
-    ).with_hint(
-        models.DataIdentifierAssociation, "INDEX(CONTENTS CONTENTS_PK)", 'oracle'
+        models.DataIdentifierAssociation.did_type.in_(dids_to_resolve),
+    ).cte(
+        recursive=True,
     )
-    for child_scope, child_name, child_type in session.execute(stmt).yield_per(5):
-        if child_type == DIDType.CONTAINER:
-            result.extend(list_child_datasets(scope=child_scope, name=child_name, session=session))
-        else:
-            result.append({'scope': child_scope, 'name': child_name, 'type': child_type})
 
-    # remove duplicate entries
-    result = {(elem['scope'], elem['name']): elem for elem in result}.values()
+    # Oracle doesn't support union() in recursive CTEs, so use UNION ALL
+    # and a "distinct" filter later
+    child_datasets_cte = initial_set.union_all(
+        select(
+            models.DataIdentifierAssociation.child_scope,
+            models.DataIdentifierAssociation.child_name,
+            models.DataIdentifierAssociation.child_type,
+        ).where(
+            models.DataIdentifierAssociation.scope == initial_set.c.child_scope,
+            models.DataIdentifierAssociation.name == initial_set.c.child_name,
+            models.DataIdentifierAssociation.did_type.in_(dids_to_resolve),
+        )
+    )
+
+    stmt = select(
+        child_datasets_cte.c.child_scope.label('scope'),
+        child_datasets_cte.c.child_name.label('name'),
+    ).distinct(
+    ).where(
+        child_datasets_cte.c.child_type == did_type,
+    )
+    if including_input_dids:
+        # Additionally, add the input did if it's of the desired type
+        stmt = stmt.union(
+            select(
+                models.DataIdentifier.scope,
+                models.DataIdentifier.name,
+            ).where(
+                models.DataIdentifier.scope == scope,
+                models.DataIdentifier.name == name,
+                models.DataIdentifier.did_type == did_type,
+            )
+        )
+    return stmt
+
+
+@transactional_session
+def list_child_datasets(
+        scope: "InternalScope",
+        name: str,
+        session: "Optional[Session]" = None
+):
+    """
+    List all child datasets of a container.
+
+    :param scope:     The scope.
+    :param name:      The name.
+    :param session:   The database session
+    :returns:         List of dids
+    :rtype:           Generator
+    """
+    stmt = list_one_did_childs_stmt(scope, name, did_type=DIDType.DATASET)
+    result = []
+    for row in session.execute(stmt):
+        result.append({'scope': row.scope, 'name': row.name})
 
     return result
 
@@ -2986,15 +3116,24 @@ def __resolve_bytes_length_events_did(
     if did.did_type == DIDType.DATASET and dynamic_depth == DIDType.FILE or \
             did.did_type == DIDType.CONTAINER and dynamic_depth in (DIDType.FILE, DIDType.DATASET):
 
-        if did.did_type == DIDType.DATASET:
-            datasets = [{'scope': did.scope, 'name': did.name}]
-        else:
-            datasets = list_child_datasets(scope=did.scope, name=did.name, session=session)
-
         use_temp_tables = config_get_bool('core', 'use_temp_tables', default=False)
-        if use_temp_tables and len(datasets) > 1:
+        if use_temp_tables:
             temp_table = temp_table_mngr(session).create_scope_name_table()
-            session.bulk_insert_mappings(temp_table, datasets)
+            if did.did_type == DIDType.DATASET:
+                stmt = insert(
+                    temp_table
+                ).values(
+                    scope=did.scope,
+                    name=did.name
+                )
+            else:
+                stmt = insert(
+                    temp_table
+                ).from_select(
+                    ['scope', 'name'],
+                    list_one_did_childs_stmt(did.scope, did.name, did_type=DIDType.DATASET),
+                )
+            session.execute(stmt)
 
             if dynamic_depth == DIDType.FILE:
                 stmt = select(
@@ -3026,6 +3165,10 @@ def __resolve_bytes_length_events_did(
             except NoResultFound:
                 bytes_, length, events = 0, 0, 0
         else:
+            if did.did_type == DIDType.DATASET:
+                datasets = [{'scope': did.scope, 'name': did.name}]
+            else:
+                datasets = list_child_datasets(scope=did.scope, name=did.name, session=session)
             for dataset in datasets:
                 if dynamic_depth == DIDType.FILE:
                     stmt = select(
diff --git a/lib/rucio/core/replica.py b/lib/rucio/core/replica.py
--- a/lib/rucio/core/replica.py
+++ b/lib/rucio/core/replica.py
@@ -25,16 +25,17 @@
 from json import dumps
 from re import match
 from struct import unpack
+from typing import TYPE_CHECKING
 from traceback import format_exc
 
 import requests
 from dogpile.cache.api import NO_VALUE
-from sqlalchemy import func, and_, or_, exists, not_, update, delete, insert
+from sqlalchemy import func, and_, or_, exists, not_, update, delete, insert, union
 from sqlalchemy.exc import DatabaseError, IntegrityError
 from sqlalchemy.orm import aliased
 from sqlalchemy.orm.exc import FlushError, NoResultFound
 from sqlalchemy.sql import label
-from sqlalchemy.sql.expression import case, select, text, false, true, null
+from sqlalchemy.sql.expression import case, select, text, false, true, null, literal
 
 import rucio.core.did
 import rucio.core.lock
@@ -57,6 +58,10 @@
 from rucio.db.sqla.util import temp_table_mngr
 from rucio.rse import rsemanager as rsemgr
 
+if TYPE_CHECKING:
+    from typing import Any, Dict, Optional, Sequence
+    from sqlalchemy.orm import Session
+
 REGION = make_region_memcached(expiration_time=60)
 
 
@@ -619,81 +624,6 @@ def get_did_from_pfns(pfns, rse_id=None, vo='def', session=None):
                 yield {pfndict[pfn]: {'scope': scope, 'name': name}}
 
 
-def _resolve_dids(dids, unavailable, ignore_availability, all_states, resolve_archives, session):
-    """
-    Resolve list of DIDs into a list of conditions.
-
-    :param dids: The list of data identifiers (DIDs).
-    :param unavailable: (deprecated) Also include unavailable replicas in the list.
-    :param ignore_availability: Ignore the RSE blocklisting.
-    :param all_states: Return all replicas whatever state they are in. Adds an extra 'states' entry in the result dictionary.
-    :param resolve_archives: When set to true, find archives which contain the replicas.
-    :param session: The database session in use.
-    """
-    did_clause, dataset_clause, file_clause, constituent_clause = [], [], [], []
-    # Accumulate all the dids which were requested explicitly (not via a container/dataset).
-    # If any replicas for these dids will be found latter, the associated did will be removed from the list,
-    # leaving, at the end, only the requested dids which didn't have any replicas at all.
-    files_wo_replica = []
-    for did in [dict(tupleized) for tupleized in set(tuple(item.items()) for item in dids)]:
-        if 'type' in did and did['type'] in (DIDType.FILE, DIDType.FILE.value) or 'did_type' in did and did['did_type'] in (DIDType.FILE, DIDType.FILE.value):  # pylint: disable=no-member
-            files_wo_replica.append({'scope': did['scope'], 'name': did['name']})
-            file_clause.append(and_(models.RSEFileAssociation.scope == did['scope'],
-                                    models.RSEFileAssociation.name == did['name']))
-
-        else:
-            did_clause.append(and_(models.DataIdentifier.scope == did['scope'],
-                                   models.DataIdentifier.name == did['name']))
-
-    if did_clause:
-        for scope, name, did_type, constituent in session.query(models.DataIdentifier.scope,
-                                                                models.DataIdentifier.name,
-                                                                models.DataIdentifier.did_type,
-                                                                models.DataIdentifier.constituent)\
-                                                         .with_hint(models.DataIdentifier, "INDEX(DIDS DIDS_PK)", 'oracle')\
-                                                         .filter(or_(*did_clause)):
-            if resolve_archives and constituent:
-                constituent_clause.append(and_(models.ConstituentAssociation.child_scope == scope,
-                                               models.ConstituentAssociation.child_name == name))
-
-            if did_type == DIDType.FILE:
-                files_wo_replica.append({'scope': scope, 'name': name})
-                file_clause.append(and_(models.RSEFileAssociation.scope == scope,
-                                        models.RSEFileAssociation.name == name))
-
-            elif did_type == DIDType.DATASET:
-                dataset_clause.append(and_(models.DataIdentifierAssociation.scope == scope,
-                                           models.DataIdentifierAssociation.name == name))
-
-            else:  # Container
-                content_query = session.query(models.DataIdentifierAssociation.child_scope,
-                                              models.DataIdentifierAssociation.child_name,
-                                              models.DataIdentifierAssociation.child_type)
-                content_query = content_query.with_hint(models.DataIdentifierAssociation, "INDEX(CONTENTS CONTENTS_PK)", 'oracle')
-                child_dids = [(scope, name)]
-                while child_dids:
-                    s, n = child_dids.pop()
-                    for tmp_did in content_query.filter_by(scope=s, name=n):
-                        if tmp_did.child_type == DIDType.DATASET:
-                            dataset_clause.append(and_(models.DataIdentifierAssociation.scope == tmp_did.child_scope,
-                                                       models.DataIdentifierAssociation.name == tmp_did.child_name))
-
-                        else:
-                            child_dids.append((tmp_did.child_scope, tmp_did.child_name))
-
-    state_clause = None
-    if not all_states:
-        if not unavailable:
-            state_clause = and_(models.RSEFileAssociation.state == ReplicaState.AVAILABLE)
-
-        else:
-            state_clause = or_(models.RSEFileAssociation.state == ReplicaState.AVAILABLE,
-                               models.RSEFileAssociation.state == ReplicaState.UNAVAILABLE,
-                               models.RSEFileAssociation.state == ReplicaState.COPYING)
-
-    return file_clause, dataset_clause, state_clause, constituent_clause, files_wo_replica
-
-
 def _pick_n_random(nrandom, generator):
     """
     Select n random elements from the generator
@@ -729,155 +659,6 @@ def _pick_n_random(nrandom, generator):
         yield r
 
 
-def _list_replicas_for_datasets(dataset_clause, state_clause, rse_clause, ignore_availability, updated_after, session):
-    """
-    List file replicas for a list of datasets.
-
-    :param session: The database session in use.
-    """
-    if not dataset_clause:
-        return
-
-    replica_query = session.query(models.DataIdentifierAssociation.child_scope,
-                                  models.DataIdentifierAssociation.child_name,
-                                  models.DataIdentifierAssociation.bytes,
-                                  models.DataIdentifierAssociation.md5,
-                                  models.DataIdentifierAssociation.adler32,
-                                  models.RSEFileAssociation.path,
-                                  models.RSEFileAssociation.state,
-                                  models.RSE.id,
-                                  models.RSE.rse,
-                                  models.RSE.rse_type,
-                                  models.RSE.volatile).\
-        with_hint(models.RSEFileAssociation,
-                  text="INDEX_RS_ASC(CONTENTS CONTENTS_PK) INDEX_RS_ASC(REPLICAS REPLICAS_PK) NO_INDEX_FFS(CONTENTS CONTENTS_PK)",
-                  dialect_name='oracle').\
-        outerjoin(models.RSEFileAssociation,
-                  and_(models.DataIdentifierAssociation.child_scope == models.RSEFileAssociation.scope,
-                       models.DataIdentifierAssociation.child_name == models.RSEFileAssociation.name)).\
-        join(models.RSE, models.RSE.id == models.RSEFileAssociation.rse_id).\
-        filter(models.RSE.deleted == false()).\
-        filter(or_(*dataset_clause)).\
-        order_by(models.DataIdentifierAssociation.child_scope,
-                 models.DataIdentifierAssociation.child_name)
-
-    if not ignore_availability:
-        replica_query = replica_query.filter(models.RSE.availability.in_((4, 5, 6, 7)))
-
-    if state_clause is not None:
-        replica_query = replica_query.filter(and_(state_clause))
-
-    if rse_clause is not None:
-        replica_query = replica_query.filter(or_(*rse_clause))
-
-    if updated_after:
-        replica_query = replica_query.filter(models.RSEFileAssociation.updated_at >= updated_after)
-
-    for scope, name, bytes_, md5, adler32, path, state, rse_id, rse, rse_type, volatile in replica_query.yield_per(500):
-        yield scope, name, None, None, bytes_, md5, adler32, path, state, rse_id, rse, rse_type, volatile
-
-
-def _list_replicas_for_constituents(constituent_clause, state_clause, files_wo_replica, rse_clause, ignore_availability, updated_after, session):
-    """
-    List file replicas for archive constituents.
-    """
-    if not constituent_clause:
-        return
-
-    constituent_query = session.query(models.ConstituentAssociation.child_scope,
-                                      models.ConstituentAssociation.child_name,
-                                      models.ConstituentAssociation.scope,
-                                      models.ConstituentAssociation.name,
-                                      models.ConstituentAssociation.bytes,
-                                      models.ConstituentAssociation.md5,
-                                      models.ConstituentAssociation.adler32,
-                                      models.RSEFileAssociation.path,
-                                      models.RSEFileAssociation.state,
-                                      models.RSE.id,
-                                      models.RSE.rse,
-                                      models.RSE.rse_type,
-                                      models.RSE.volatile). \
-        with_hint(models.RSEFileAssociation,
-                  text="INDEX_RS_ASC(CONTENTS CONTENTS_PK) INDEX_RS_ASC(REPLICAS REPLICAS_PK) NO_INDEX_FFS(CONTENTS CONTENTS_PK)",
-                  dialect_name='oracle'). \
-        with_hint(models.ConstituentAssociation, "INDEX(ARCHIVE_CONTENTS ARCH_CONTENTS_PK)", 'oracle'). \
-        outerjoin(models.RSEFileAssociation,
-                  and_(models.ConstituentAssociation.scope == models.RSEFileAssociation.scope,
-                       models.ConstituentAssociation.name == models.RSEFileAssociation.name)). \
-        join(models.RSE, models.RSE.id == models.RSEFileAssociation.rse_id). \
-        filter(models.RSE.deleted == false()). \
-        filter(or_(*constituent_clause)). \
-        order_by(models.ConstituentAssociation.child_scope,
-                 models.ConstituentAssociation.child_name)
-
-    if not ignore_availability:
-        constituent_query = constituent_query.filter(models.RSE.availability.in_((4, 5, 6, 7)))
-
-    if state_clause is not None:
-        constituent_query = constituent_query.filter(and_(state_clause))
-
-    if rse_clause is not None:
-        constituent_query = constituent_query.filter(or_(*rse_clause))
-
-    if updated_after:
-        constituent_query = constituent_query.filter(models.RSEFileAssociation.updated_at >= updated_after)
-
-    for replica in constituent_query.yield_per(500):
-        scope, name = replica[0], replica[1]
-        {'scope': scope, 'name': name} in files_wo_replica and files_wo_replica.remove({'scope': scope, 'name': name})
-        yield replica
-
-
-def _list_replicas_for_files(file_clause, state_clause, files_wo_replica, rse_clause, ignore_availability, updated_after, session):
-    """
-    List file replicas for a list of files.
-
-    :param session: The database session in use.
-    """
-    if not file_clause:
-        return
-
-    for replica_condition in chunks(file_clause, 50):
-        filters = [
-            models.RSEFileAssociation.rse_id == models.RSE.id,
-            models.RSE.deleted == false(),
-            or_(*replica_condition),
-        ]
-
-        if not ignore_availability:
-            filters.append(models.RSE.availability.in_((4, 5, 6, 7)))
-
-        if state_clause is not None:
-            filters.append(state_clause)
-
-        if rse_clause:
-            filters.append(or_(*rse_clause))
-
-        if updated_after:
-            filters.append(models.RSEFileAssociation.updated_at >= updated_after)
-
-        replica_query = session.query(
-            models.RSEFileAssociation.scope,
-            models.RSEFileAssociation.name,
-            models.RSEFileAssociation.bytes,
-            models.RSEFileAssociation.md5,
-            models.RSEFileAssociation.adler32,
-            models.RSEFileAssociation.path,
-            models.RSEFileAssociation.state,
-            models.RSE.id,
-            models.RSE.rse,
-            models.RSE.rse_type,
-            models.RSE.volatile,
-        ) \
-            .filter(and_(*filters)) \
-            .order_by(models.RSEFileAssociation.scope, models.RSEFileAssociation.name) \
-            .with_hint(models.RSEFileAssociation, text="INDEX(REPLICAS REPLICAS_PK)", dialect_name='oracle')
-
-        for scope, name, bytes_, md5, adler32, path, state, rse_id, rse, rse_type, volatile in replica_query.all():
-            {'scope': scope, 'name': name} in files_wo_replica and files_wo_replica.remove({'scope': scope, 'name': name})
-            yield scope, name, None, None, bytes_, md5, adler32, path, state, rse_id, rse, rse_type, volatile
-
-
 def _list_files_wo_replicas(files_wo_replica, session):
     if files_wo_replica:
         file_wo_clause = []
@@ -969,19 +750,8 @@ def _sort_replica_file_pfns(file):
             file['rses'][t_rse] = [t_pfn]
 
 
-def _list_replicas(dataset_clause, file_clause, state_clause, show_pfns,
-                   schemes, files_wo_replica, rse_clause, client_location, domain,
-                   sign_urls, signature_lifetime, constituent_clause, resolve_parents,
-                   updated_after, filters, ignore_availability,
-                   session):
-
-    # iterator which merges multiple sorted replica sources into a combine sorted result without loading everything into the memory
-    replicas = heapq.merge(
-        _list_replicas_for_datasets(dataset_clause, state_clause, rse_clause, ignore_availability, updated_after, session),
-        _list_replicas_for_files(file_clause, state_clause, files_wo_replica, rse_clause, ignore_availability, updated_after, session),
-        _list_replicas_for_constituents(constituent_clause, state_clause, files_wo_replica, rse_clause, ignore_availability, updated_after, session),
-        key=lambda t: (t[0], t[1]),  # sort by scope, name
-    )
+def _list_replicas(replicas, show_pfns, schemes, files_wo_replica, client_location, domain,
+                   sign_urls, signature_lifetime, resolve_parents, filters, session):
 
     # we need to retain knowledge of the original domain selection by the user
     # in case we have to loop over replicas with a potential outgoing proxy
@@ -999,7 +769,8 @@ def _list_replicas(dataset_clause, file_clause, state_clause, show_pfns,
     file, tmp_protocols, rse_info, pfns_cache = {}, {}, {}, {}
 
     for scope, name, archive_scope, archive_name, bytes_, md5, adler32, path, state, rse_id, rse, rse_type, volatile in replicas:
-
+        if isinstance(archive_scope, str):
+            archive_scope = InternalScope(archive_scope, fromExternal=False)
         pfns = []
 
         # reset the domain selection to original user's choice (as this could get overwritten each iteration)
@@ -1207,13 +978,25 @@ def _list_replicas(dataset_clause, file_clause, state_clause, show_pfns,
 
 
 @stream_session
-def list_replicas(dids, schemes=None, unavailable=False, request_id=None,
-                  ignore_availability=True, all_states=False, pfns=True,
-                  rse_expression=None, client_location=None, domain=None,
-                  sign_urls=False, signature_lifetime=None, resolve_archives=True,
-                  resolve_parents=False, nrandom=None,
-                  updated_after=None,
-                  session=None):
+def list_replicas(
+        dids: "Sequence[Dict[str, Any]]",
+        schemes: "Optional[str]" = None,
+        unavailable: bool = False,
+        request_id: "Optional[str]" = None,
+        ignore_availability: bool = True,
+        all_states: bool = False,
+        pfns: bool = True,
+        rse_expression: "Optional[str]" = None,
+        client_location: "Optional[Dict[str, Any]]" = None,
+        domain: "Optional[str]" = None,
+        sign_urls: bool = False,
+        signature_lifetime: "Optional[int]" = None,
+        resolve_archives: bool = True,
+        resolve_parents: bool = False,
+        nrandom: "Optional[int]" = None,
+        updated_after: "Optional[datetime]" = None,
+        session: "Optional[Session]" = None,
+):
     """
     List file replicas for a list of data identifiers (DIDs).
 
@@ -1233,32 +1016,336 @@ def list_replicas(dids, schemes=None, unavailable=False, request_id=None,
     :param updated_after: datetime (UTC time), only return replicas updated after this time
     :param session: The database session in use.
     """
+    # For historical reasons:
+    # - list_replicas([some_file_did]), must return the file even if it doesn't have replicas
+    # - list_replicas([some_collection_did]) must only return files with replicas
+    use_temp_tables = config_get_bool('core', 'use_temp_tables', default=False)
+    if use_temp_tables:
+        yield from _list_replicas_with_temp_tables(
+            dids, schemes, unavailable, request_id, ignore_availability, all_states, pfns, rse_expression, client_location,
+            domain, sign_urls, signature_lifetime, resolve_archives, resolve_parents, nrandom, updated_after, session
+        )
+    else:
+        yield from _list_replicas_wo_temp_tables(
+            dids, schemes, unavailable, request_id, ignore_availability, all_states, pfns, rse_expression, client_location,
+            domain, sign_urls, signature_lifetime, resolve_archives, resolve_parents, nrandom, updated_after, session
+        )
+
+
+def _list_replicas_with_temp_tables(
+        dids: "Sequence[Dict[str, Any]]",
+        schemes: "Optional[str]" = None,
+        unavailable: bool = False,
+        request_id: "Optional[str]" = None,
+        ignore_availability: bool = True,
+        all_states: bool = False,
+        pfns: bool = True,
+        rse_expression: "Optional[str]" = None,
+        client_location: "Optional[Dict[str, Any]]" = None,
+        domain: "Optional[str]" = None,
+        sign_urls: bool = False,
+        signature_lifetime: "Optional[int]" = None,
+        resolve_archives: bool = True,
+        resolve_parents: bool = False,
+        nrandom: "Optional[int]" = None,
+        updated_after: "Optional[datetime]" = None,
+        session: "Optional[Session]" = None,
+):
+
+    def _replicas_filter_subquery():
+        """
+        Build the sub-query used to filter replicas according to list_replica's input arguments
+        """
+        stmt = select(
+            models.RSEFileAssociation.scope,
+            models.RSEFileAssociation.name,
+            models.RSEFileAssociation.path,
+            models.RSEFileAssociation.state,
+            models.RSEFileAssociation.bytes,
+            models.RSEFileAssociation.md5,
+            models.RSEFileAssociation.adler32,
+            models.RSE.id.label('rse_id'),
+            models.RSE.rse.label('rse_name'),
+            models.RSE.rse_type,
+            models.RSE.volatile,
+        ).join(
+            models.RSE,
+            and_(models.RSE.id == models.RSEFileAssociation.rse_id,
+                 models.RSE.deleted == false())
+        )
+
+        if not ignore_availability:
+            stmt = stmt.where(models.RSE.availability.in_((4, 5, 6, 7)))
+
+        if updated_after:
+            stmt = stmt.where(models.RSEFileAssociation.updated_at >= updated_after)
+
+        if rse_expression:
+            rses = parse_expression(expression=rse_expression, filter_=filter_, session=session)
+            # When the number of RSEs is small, don't go through the overhead of
+            # creating and using a temporary table. Rely on a simple "in" query.
+            # The number "4" was picked without any particular reason
+            if 0 < len(rses) < 4:
+                stmt = stmt.where(models.RSE.id.in_([rse['id'] for rse in rses]))
+            else:
+                rses_temp_table = temp_table_mngr(session).create_id_table()
+                session.bulk_insert_mappings(rses_temp_table, [{'id': rse['id'] for rse in rses}])
+                stmt = stmt.join(rses_temp_table, models.RSE.id == rses_temp_table.id)
+
+        if not all_states:
+            if not unavailable:
+                state_clause = models.RSEFileAssociation.state == ReplicaState.AVAILABLE
+            else:
+                state_clause = or_(
+                    models.RSEFileAssociation.state == ReplicaState.AVAILABLE,
+                    models.RSEFileAssociation.state == ReplicaState.UNAVAILABLE,
+                    models.RSEFileAssociation.state == ReplicaState.COPYING
+                )
+            stmt = stmt.where(state_clause)
+
+        return stmt.subquery()
+
+    def _resolve_collection_files(temp_table, session):
+        """
+        Find all FILE dids contained in collections from temp_table and return them in a newly
+        created temporary table.
+        """
+        resolved_files_temp_table = temp_table_mngr(session).create_scope_name_table()
+
+        stmt = insert(
+            resolved_files_temp_table
+        ).from_select(
+            ['scope', 'name'],
+            rucio.core.did.list_child_dids_stmt(temp_table, did_type=DIDType.FILE)
+        )
+        result = session.execute(stmt)
+
+        return result.rowcount, resolved_files_temp_table
+
+    def _list_replicas_for_collection_files_stmt(temp_table, replicas_subquery):
+        """
+        Build a query for listing replicas of files resolved from containers/datasets
+
+        The query assumes that temp_table only contains DIDs of type FILE.
+        """
+        return select(
+            temp_table.scope.label('scope'),
+            temp_table.name.label('name'),
+            literal(None).label('archive_scope'),
+            literal(None).label('archive_name'),
+            replicas_subquery.c.bytes,
+            replicas_subquery.c.md5,
+            replicas_subquery.c.adler32,
+            replicas_subquery.c.path,
+            replicas_subquery.c.state,
+            replicas_subquery.c.rse_id,
+            replicas_subquery.c.rse_name,
+            replicas_subquery.c.rse_type,
+            replicas_subquery.c.volatile,
+        ).join_from(
+            temp_table,
+            replicas_subquery,
+            and_(replicas_subquery.c.scope == temp_table.scope,
+                 replicas_subquery.c.name == temp_table.name),
+        )
+
+    def _list_replicas_for_constituents_stmt(temp_table, replicas_subquery):
+        """
+        Build a query for listing replicas of archives containing the files(constituents) given as input.
+        i.e. for a file scope:file.log which exists in scope:archive.tar.gz, it will return the replicas
+        (rse, path, state, etc) of archive.tar.gz, but with bytes/md5/adler of file.log
+        """
+        return select(
+            models.ConstituentAssociation.child_scope.label('scope'),
+            models.ConstituentAssociation.child_name.label('name'),
+            models.ConstituentAssociation.scope.label('archive_scope'),
+            models.ConstituentAssociation.name.label('archive_name'),
+            models.ConstituentAssociation.bytes,
+            models.ConstituentAssociation.md5,
+            models.ConstituentAssociation.adler32,
+            replicas_subquery.c.path,
+            replicas_subquery.c.state,
+            replicas_subquery.c.rse_id,
+            replicas_subquery.c.rse_name,
+            replicas_subquery.c.rse_type,
+            replicas_subquery.c.volatile,
+        ).join_from(
+            temp_table,
+            models.DataIdentifier,
+            and_(models.DataIdentifier.scope == temp_table.scope,
+                 models.DataIdentifier.name == temp_table.name,
+                 models.DataIdentifier.did_type == DIDType.FILE,
+                 models.DataIdentifier.constituent == true()),
+        ).join(
+            models.ConstituentAssociation,
+            and_(models.ConstituentAssociation.child_scope == temp_table.scope,
+                 models.ConstituentAssociation.child_name == temp_table.name)
+        ).join(
+            replicas_subquery,
+            and_(replicas_subquery.c.scope == models.ConstituentAssociation.scope,
+                 replicas_subquery.c.name == models.ConstituentAssociation.name),
+        )
+
+    def _list_replicas_for_input_files_stmt(temp_table, replicas_subquery):
+        """
+        Builds a query which list the replicas of FILEs from users input, but ignores
+        collections in the same input.
+
+        Note: These FILE dids must be returned to the user even if they don't have replicas,
+        hence the outerjoin against the replicas_subquery.
+        """
+        return select(
+            temp_table.scope.label('scope'),
+            temp_table.name.label('name'),
+            literal(None).label('archive_scope'),
+            literal(None).label('archive_name'),
+            models.DataIdentifier.bytes,
+            models.DataIdentifier.md5,
+            models.DataIdentifier.adler32,
+            replicas_subquery.c.path,
+            replicas_subquery.c.state,
+            replicas_subquery.c.rse_id,
+            replicas_subquery.c.rse_name,
+            replicas_subquery.c.rse_type,
+            replicas_subquery.c.volatile,
+        ).join_from(
+            temp_table,
+            models.DataIdentifier,
+            and_(models.DataIdentifier.scope == temp_table.scope,
+                 models.DataIdentifier.name == temp_table.name,
+                 models.DataIdentifier.did_type == DIDType.FILE),
+        ).outerjoin(
+            replicas_subquery,
+            and_(replicas_subquery.c.scope == temp_table.scope,
+                 replicas_subquery.c.name == temp_table.name),
+        )
+
+    def _inspect_dids(temp_table, session):
+        """
+        Find how many files, collections and constituents are among the dids in the temp_table
+        """
+        stmt = select(
+            func.sum(
+                case([(models.DataIdentifier.did_type == DIDType.FILE, 1)], else_=0)
+            ).label('num_files'),
+            func.sum(
+                case([(models.DataIdentifier.did_type.in_([DIDType.CONTAINER, DIDType.DATASET]), 1)], else_=0)
+            ).label('num_collections'),
+            func.sum(
+                case([(models.DataIdentifier.constituent, 1)], else_=0)
+            ).label('num_constituents'),
+        ).join_from(
+            temp_table,
+            models.DataIdentifier,
+            and_(models.DataIdentifier.scope == temp_table.scope,
+                 models.DataIdentifier.name == temp_table.name),
+        )
+        num_files, num_collections, num_constituents = session.execute(stmt).one()  # returns None on empty input
+        return num_files or 0, num_collections or 0, num_constituents or 0
+
     if dids:
         filter_ = {'vo': dids[0]['scope'].vo}
     else:
         filter_ = {'vo': 'def'}
 
-    file_clause, dataset_clause, state_clause, constituent_clause, files_wo_replica = _resolve_dids(
-        dids=dids,
-        unavailable=unavailable,
-        ignore_availability=ignore_availability,
-        all_states=all_states,
-        resolve_archives=resolve_archives,
-        session=session
-    )
+    dids = {(did['scope'], did['name']): did for did in dids}  # Deduplicate input
+    input_dids_temp_table = temp_table_mngr(session).create_scope_name_table()
+    session.bulk_insert_mappings(input_dids_temp_table, [{'scope': s, 'name': n} for s, n in dids])
+
+    num_files, num_collections, num_constituents = _inspect_dids(input_dids_temp_table, session)
+
+    num_files_in_collections, resolved_files_temp_table = 0, None
+    if num_collections:
+        num_files_in_collections, resolved_files_temp_table = _resolve_collection_files(input_dids_temp_table, session)
+
+    replicas_subquery = _replicas_filter_subquery()
+    replica_sources = []
+    if num_files:
+        replica_sources.append(
+            _list_replicas_for_input_files_stmt(input_dids_temp_table, replicas_subquery)
+        )
+    if num_constituents and resolve_archives:
+        replica_sources.append(
+            _list_replicas_for_constituents_stmt(input_dids_temp_table, replicas_subquery)
+        )
+    if num_files_in_collections:
+        replica_sources.append(
+            _list_replicas_for_collection_files_stmt(resolved_files_temp_table, replicas_subquery)
+        )
+
+    if not replica_sources:
+        return
+
+    # In the simple case that somebody calls list_replicas on big collections with nrandom set,
+    # opportunistically try to reduce the number of fetched and analyzed rows.
+    if (
+            nrandom
+            # Only try this optimisation if list_replicas was called on collection(s).
+            # I didn't consider handling the case when list_replica is called with a mix of
+            # file/archive/collection dids: database queries in those cases are more complex
+            # and people don't usually call list_replicas with nrandom on file/archive_constituents anyway.
+            and (num_files_in_collections and not num_constituents and not num_files)
+            # The following code introduces overhead if it fails to pick n random replicas.
+            # Only execute when nrandom is much smaller than the total number of candidate files.
+            # 64 was picked without any particular reason as "seems good enough".
+            and 0 < nrandom < num_files_in_collections / 64
+    ):
+        # Randomly select a subset of file DIDs which have at least one replica matching the RSE/replica
+        # filters applied on database side. Some filters are applied later in python code
+        # (for example: scheme; or client_location/domain). We don't have any guarantee that
+        # those, python, filters will not drop the replicas which we just selected randomly.
+        stmt = select(
+            resolved_files_temp_table.scope.label('scope'),
+            resolved_files_temp_table.name.label('name'),
+        ).where(
+            exists(
+                select([1])
+            ).where(
+                replicas_subquery.c.scope == resolved_files_temp_table.scope,
+                replicas_subquery.c.name == resolved_files_temp_table.name
+            )
+        ).order_by(
+            func.random()
+        ).limit(
+            # slightly overshoot to reduce the probability that python-side filtering will
+            # leave us with less than nrandom replicas.
+            nrandom * 4
+        )
+        # Re-use input temp table. We don't need its content anymore
+        random_dids_temp_table = input_dids_temp_table
+        session.execute(delete(random_dids_temp_table))
+        session.execute(insert(random_dids_temp_table).from_select(['scope', 'name'], stmt))
+
+        # Fetch all replicas for randomly selected dids and apply filters on python side
+        stmt = _list_replicas_for_collection_files_stmt(random_dids_temp_table, replicas_subquery)
+        stmt = stmt.order_by('scope', 'name')
+        replica_tuples = session.execute(stmt)
+        random_replicas = list(
+            _pick_n_random(
+                nrandom,
+                _list_replicas(replica_tuples, pfns, schemes, [], client_location, domain,
+                               sign_urls, signature_lifetime, resolve_parents, filter_, session)
+            )
+        )
+        if len(random_replicas) == nrandom:
+            yield from random_replicas
+            return
+        else:
+            # Our opportunistic attempt to pick nrandom replicas without fetching all database rows failed,
+            # continue with the normal list_replicas flow and fetch all replicas
+            pass
 
-    rse_clause = []
-    if rse_expression:
-        for rse in parse_expression(expression=rse_expression, filter_=filter_, session=session):
-            rse_clause.append(models.RSEFileAssociation.rse_id == rse['id'])
+    elif len(replica_sources) == 1:
+        stmt = replica_sources[0]
+    else:
+        stmt = union(*replica_sources)
+    stmt = stmt.order_by('scope', 'name')
+    replica_tuples = session.execute(stmt)
 
     yield from _pick_n_random(
         nrandom,
-        _list_replicas(dataset_clause, file_clause, state_clause, pfns,
-                       schemes, files_wo_replica, rse_clause, client_location, domain,
-                       sign_urls, signature_lifetime, constituent_clause, resolve_parents,
-                       updated_after, filter_, ignore_availability,
-                       session)
+        _list_replicas(replica_tuples, pfns, schemes, [], client_location, domain,
+                       sign_urls, signature_lifetime, resolve_parents, filter_, session)
     )
 
 
@@ -4034,3 +4121,280 @@ def get_RSEcoverage_of_dataset(scope, name, session=None):
             result[rse_id] = total
 
     return result
+
+
+def _resolve_dids_wo_temp_tables(dids, unavailable, ignore_availability, all_states, resolve_archives, session):
+    """
+    Resolve list of DIDs into a list of conditions.
+
+    :param dids: The list of data identifiers (DIDs).
+    :param unavailable: (deprecated) Also include unavailable replicas in the list.
+    :param ignore_availability: Ignore the RSE blocklisting.
+    :param all_states: Return all replicas whatever state they are in. Adds an extra 'states' entry in the result dictionary.
+    :param resolve_archives: When set to true, find archives which contain the replicas.
+    :param session: The database session in use.
+    """
+    did_clause, dataset_clause, file_clause, constituent_clause = [], [], [], []
+    # Accumulate all the dids which were requested explicitly (not via a container/dataset).
+    # If any replicas for these dids will be found latter, the associated did will be removed from the list,
+    # leaving, at the end, only the requested dids which didn't have any replicas at all.
+    files_wo_replica = []
+    for did in [dict(tupleized) for tupleized in set(tuple(item.items()) for item in dids)]:
+        if 'type' in did and did['type'] in (DIDType.FILE, DIDType.FILE.value) or 'did_type' in did and did['did_type'] in (DIDType.FILE, DIDType.FILE.value):  # pylint: disable=no-member
+            files_wo_replica.append({'scope': did['scope'], 'name': did['name']})
+            file_clause.append(and_(models.RSEFileAssociation.scope == did['scope'],
+                                    models.RSEFileAssociation.name == did['name']))
+
+        else:
+            did_clause.append(and_(models.DataIdentifier.scope == did['scope'],
+                                   models.DataIdentifier.name == did['name']))
+
+    if did_clause:
+        for scope, name, did_type, constituent in session.query(models.DataIdentifier.scope,
+                                                                models.DataIdentifier.name,
+                                                                models.DataIdentifier.did_type,
+                                                                models.DataIdentifier.constituent) \
+                .with_hint(models.DataIdentifier, "INDEX(DIDS DIDS_PK)", 'oracle') \
+                .filter(or_(*did_clause)):
+            if resolve_archives and constituent:
+                constituent_clause.append(and_(models.ConstituentAssociation.child_scope == scope,
+                                               models.ConstituentAssociation.child_name == name))
+
+            if did_type == DIDType.FILE:
+                files_wo_replica.append({'scope': scope, 'name': name})
+                file_clause.append(and_(models.RSEFileAssociation.scope == scope,
+                                        models.RSEFileAssociation.name == name))
+
+            elif did_type == DIDType.DATASET:
+                dataset_clause.append(and_(models.DataIdentifierAssociation.scope == scope,
+                                           models.DataIdentifierAssociation.name == name))
+
+            else:  # Container
+                content_query = session.query(models.DataIdentifierAssociation.child_scope,
+                                              models.DataIdentifierAssociation.child_name,
+                                              models.DataIdentifierAssociation.child_type)
+                content_query = content_query.with_hint(models.DataIdentifierAssociation, "INDEX(CONTENTS CONTENTS_PK)", 'oracle')
+                child_dids = [(scope, name)]
+                while child_dids:
+                    s, n = child_dids.pop()
+                    for tmp_did in content_query.filter_by(scope=s, name=n):
+                        if tmp_did.child_type == DIDType.DATASET:
+                            dataset_clause.append(and_(models.DataIdentifierAssociation.scope == tmp_did.child_scope,
+                                                       models.DataIdentifierAssociation.name == tmp_did.child_name))
+
+                        else:
+                            child_dids.append((tmp_did.child_scope, tmp_did.child_name))
+
+    state_clause = None
+    if not all_states:
+        if not unavailable:
+            state_clause = and_(models.RSEFileAssociation.state == ReplicaState.AVAILABLE)
+
+        else:
+            state_clause = or_(models.RSEFileAssociation.state == ReplicaState.AVAILABLE,
+                               models.RSEFileAssociation.state == ReplicaState.UNAVAILABLE,
+                               models.RSEFileAssociation.state == ReplicaState.COPYING)
+
+    return file_clause, dataset_clause, state_clause, constituent_clause, files_wo_replica
+
+
+def _list_replicas_for_datasets_wo_temp_tables(dataset_clause, state_clause, rse_clause, ignore_availability, updated_after, session):
+    """
+    List file replicas for a list of datasets.
+
+    :param session: The database session in use.
+    """
+    if not dataset_clause:
+        return
+
+    replica_query = session.query(models.DataIdentifierAssociation.child_scope,
+                                  models.DataIdentifierAssociation.child_name,
+                                  models.DataIdentifierAssociation.bytes,
+                                  models.DataIdentifierAssociation.md5,
+                                  models.DataIdentifierAssociation.adler32,
+                                  models.RSEFileAssociation.path,
+                                  models.RSEFileAssociation.state,
+                                  models.RSE.id,
+                                  models.RSE.rse,
+                                  models.RSE.rse_type,
+                                  models.RSE.volatile). \
+        with_hint(models.RSEFileAssociation,
+                  text="INDEX_RS_ASC(CONTENTS CONTENTS_PK) INDEX_RS_ASC(REPLICAS REPLICAS_PK) NO_INDEX_FFS(CONTENTS CONTENTS_PK)",
+                  dialect_name='oracle'). \
+        outerjoin(models.RSEFileAssociation,
+                  and_(models.DataIdentifierAssociation.child_scope == models.RSEFileAssociation.scope,
+                       models.DataIdentifierAssociation.child_name == models.RSEFileAssociation.name)). \
+        join(models.RSE, models.RSE.id == models.RSEFileAssociation.rse_id). \
+        filter(models.RSE.deleted == false()). \
+        filter(or_(*dataset_clause)). \
+        order_by(models.DataIdentifierAssociation.child_scope,
+                 models.DataIdentifierAssociation.child_name)
+
+    if not ignore_availability:
+        replica_query = replica_query.filter(models.RSE.availability.in_((4, 5, 6, 7)))
+
+    if state_clause is not None:
+        replica_query = replica_query.filter(and_(state_clause))
+
+    if rse_clause is not None:
+        replica_query = replica_query.filter(or_(*rse_clause))
+
+    if updated_after:
+        replica_query = replica_query.filter(models.RSEFileAssociation.updated_at >= updated_after)
+
+    for scope, name, bytes_, md5, adler32, path, state, rse_id, rse, rse_type, volatile in replica_query.yield_per(500):
+        yield scope, name, None, None, bytes_, md5, adler32, path, state, rse_id, rse, rse_type, volatile
+
+
+def _list_replicas_for_constituents_wo_temp_tables(constituent_clause, state_clause, files_wo_replica, rse_clause, ignore_availability, updated_after, session):
+    """
+    List file replicas for archive constituents.
+    """
+    if not constituent_clause:
+        return
+
+    constituent_query = session.query(models.ConstituentAssociation.child_scope,
+                                      models.ConstituentAssociation.child_name,
+                                      models.ConstituentAssociation.scope,
+                                      models.ConstituentAssociation.name,
+                                      models.ConstituentAssociation.bytes,
+                                      models.ConstituentAssociation.md5,
+                                      models.ConstituentAssociation.adler32,
+                                      models.RSEFileAssociation.path,
+                                      models.RSEFileAssociation.state,
+                                      models.RSE.id,
+                                      models.RSE.rse,
+                                      models.RSE.rse_type,
+                                      models.RSE.volatile). \
+        with_hint(models.RSEFileAssociation,
+                  text="INDEX_RS_ASC(CONTENTS CONTENTS_PK) INDEX_RS_ASC(REPLICAS REPLICAS_PK) NO_INDEX_FFS(CONTENTS CONTENTS_PK)",
+                  dialect_name='oracle'). \
+        with_hint(models.ConstituentAssociation, "INDEX(ARCHIVE_CONTENTS ARCH_CONTENTS_PK)", 'oracle'). \
+        outerjoin(models.RSEFileAssociation,
+                  and_(models.ConstituentAssociation.scope == models.RSEFileAssociation.scope,
+                       models.ConstituentAssociation.name == models.RSEFileAssociation.name)). \
+        join(models.RSE, models.RSE.id == models.RSEFileAssociation.rse_id). \
+        filter(models.RSE.deleted == false()). \
+        filter(or_(*constituent_clause)). \
+        order_by(models.ConstituentAssociation.child_scope,
+                 models.ConstituentAssociation.child_name)
+
+    if not ignore_availability:
+        constituent_query = constituent_query.filter(models.RSE.availability.in_((4, 5, 6, 7)))
+
+    if state_clause is not None:
+        constituent_query = constituent_query.filter(and_(state_clause))
+
+    if rse_clause is not None:
+        constituent_query = constituent_query.filter(or_(*rse_clause))
+
+    if updated_after:
+        constituent_query = constituent_query.filter(models.RSEFileAssociation.updated_at >= updated_after)
+
+    for replica in constituent_query.yield_per(500):
+        scope, name = replica[0], replica[1]
+        {'scope': scope, 'name': name} in files_wo_replica and files_wo_replica.remove({'scope': scope, 'name': name})
+        yield replica
+
+
+def _list_replicas_for_files_wo_temp_tables(file_clause, state_clause, files_wo_replica, rse_clause, ignore_availability, updated_after, session):
+    """
+    List file replicas for a list of files.
+
+    :param session: The database session in use.
+    """
+    if not file_clause:
+        return
+
+    for replica_condition in chunks(file_clause, 50):
+        filters = [
+            models.RSEFileAssociation.rse_id == models.RSE.id,
+            models.RSE.deleted == false(),
+            or_(*replica_condition)
+        ]
+
+        if not ignore_availability:
+            filters.append(models.RSE.availability.in_((4, 5, 6, 7)))
+
+        if state_clause is not None:
+            filters.append(state_clause)
+
+        if rse_clause:
+            filters.append(or_(*rse_clause))
+
+        if updated_after:
+            filters.append(models.RSEFileAssociation.updated_at >= updated_after)
+
+        replica_query = session.query(
+            models.RSEFileAssociation.scope,
+            models.RSEFileAssociation.name,
+            models.RSEFileAssociation.bytes,
+            models.RSEFileAssociation.md5,
+            models.RSEFileAssociation.adler32,
+            models.RSEFileAssociation.path,
+            models.RSEFileAssociation.state,
+            models.RSE.id,
+            models.RSE.rse,
+            models.RSE.rse_type,
+            models.RSE.volatile,
+        ) \
+            .filter(and_(*filters)) \
+            .order_by(models.RSEFileAssociation.scope, models.RSEFileAssociation.name) \
+            .with_hint(models.RSEFileAssociation, text="INDEX(REPLICAS REPLICAS_PK)", dialect_name='oracle')
+
+        for scope, name, bytes_, md5, adler32, path, state, rse_id, rse, rse_type, volatile in replica_query.all():
+            {'scope': scope, 'name': name} in files_wo_replica and files_wo_replica.remove({'scope': scope, 'name': name})
+            yield scope, name, None, None, bytes_, md5, adler32, path, state, rse_id, rse, rse_type, volatile
+
+
+def _list_replicas_wo_temp_tables(
+        dids: "Sequence[Dict[str, Any]]",
+        schemes: "Optional[str]" = None,
+        unavailable: bool = False,
+        request_id: "Optional[str]" = None,
+        ignore_availability: bool = True,
+        all_states: bool = False,
+        pfns: bool = True,
+        rse_expression: "Optional[str]" = None,
+        client_location: "Optional[Dict[str, Any]]" = None,
+        domain: "Optional[str]" = None,
+        sign_urls: bool = False,
+        signature_lifetime: "Optional[int]" = None,
+        resolve_archives: bool = True,
+        resolve_parents: bool = False,
+        nrandom: "Optional[int]" = None,
+        updated_after: "Optional[datetime]" = None,
+        session: "Optional[Session]" = None,
+):
+    if dids:
+        filter_ = {'vo': dids[0]['scope'].vo}
+    else:
+        filter_ = {'vo': 'def'}
+
+    file_clause, dataset_clause, state_clause, constituent_clause, files_wo_replica = _resolve_dids_wo_temp_tables(
+        dids=dids,
+        unavailable=unavailable,
+        ignore_availability=ignore_availability,
+        all_states=all_states,
+        resolve_archives=resolve_archives,
+        session=session
+    )
+
+    rse_clause = []
+    if rse_expression:
+        for rse in parse_expression(expression=rse_expression, filter_=filter_, session=session):
+            rse_clause.append(models.RSEFileAssociation.rse_id == rse['id'])
+
+    # iterator which merges multiple sorted replica sources into a combine sorted result without loading everything into the memory
+    replica_tuples = heapq.merge(
+        _list_replicas_for_datasets_wo_temp_tables(dataset_clause, state_clause, rse_clause, ignore_availability, updated_after, session),
+        _list_replicas_for_files_wo_temp_tables(file_clause, state_clause, files_wo_replica, rse_clause, ignore_availability, updated_after, session),
+        _list_replicas_for_constituents_wo_temp_tables(constituent_clause, state_clause, files_wo_replica, rse_clause, ignore_availability, updated_after, session),
+        key=lambda t: (t[0], t[1]),  # sort by scope, name
+    )
+
+    yield from _pick_n_random(
+        nrandom,
+        _list_replicas(replica_tuples, pfns, schemes, files_wo_replica, client_location, domain,
+                       sign_urls, signature_lifetime, resolve_parents, filter_, session)
+    )


Test patch:
diff --git a/etc/docker/test/matrix.yml b/etc/docker/test/matrix.yml
--- a/etc/docker/test/matrix.yml
+++ b/etc/docker/test/matrix.yml
@@ -13,7 +13,14 @@ dists:
       python:
         - "3.9"
 python:
-  - "3.6"
+  - id: "3.6"
+    allow:
+      suites:
+        - syntax
+        - docs
+        - client_syntax
+        - remote_dbs
+        - multi_vo
   - id: "3.7"
     allow:
       suites:
@@ -22,6 +29,8 @@ python:
     allow:
       suites:
         - py37py38
+        - client
+        - sqlite
   - "3.9"
 suites:
   - id: syntax
@@ -33,11 +42,13 @@ suites:
     RUN_HTTPD: false
   - id: client
     RDBMS: sqlite
-  - id: all
+  - id: remote_dbs
     RDBMS:
       - oracle
       - mysql8
       - postgres14
+  - id: sqlite
+    RDBMS:
       - sqlite
   - id: multi_vo
     RDBMS: postgres14
diff --git a/lib/rucio/tests/test_replica_recoverer.py b/lib/rucio/tests/test_replica_recoverer.py
--- a/lib/rucio/tests/test_replica_recoverer.py
+++ b/lib/rucio/tests/test_replica_recoverer.py
@@ -227,8 +227,8 @@ def test_replica_recoverer(self):
                 assert replica['states'][self.rse4suspicious_id] == 'AVAILABLE'
                 assert (self.rse4recovery_id in replica['states']) is False
             if replica['name'] == path.basename(self.tmp_file4):
-                # The key 'state' only exists if the replica is available on at least one RSE. It shouldn't exist for tmp_file4.
-                assert ('states' in replica) is False
+                # The 'states' should be empty if the replica isn't available on at least one RSE
+                assert not replica.get('states')
             if replica['name'] == path.basename(self.tmp_file5):
                 assert replica['states'][self.rse4suspicious_id] == 'AVAILABLE'
                 assert (self.rse4recovery_id in replica['states']) is False