Skip to content

Commit 1f75083

Browse files
committed
refactor: Inject API client into AliasResolver instead of caching globally
1 parent e8e185b commit 1f75083

6 files changed

Lines changed: 119 additions & 61 deletions

File tree

src/apify/storage_clients/_apify/_alias_resolving.py

Lines changed: 28 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,15 @@
44
from asyncio import Lock
55
from functools import cached_property
66
from logging import getLogger
7-
from typing import TYPE_CHECKING, ClassVar, Literal, NamedTuple, overload
8-
9-
from apify_client import ApifyClientAsync
7+
from typing import TYPE_CHECKING, ClassVar, Literal, overload
108

119
from ._utils import hash_api_public_base_url_and_token
1210

1311
if TYPE_CHECKING:
1412
from collections.abc import Callable
1513
from types import TracebackType
1614

15+
from apify_client import ApifyClientAsync
1716
from apify_client._resource_clients import (
1817
DatasetClientAsync,
1918
DatasetCollectionClientAsync,
@@ -35,6 +34,7 @@ async def open_by_alias(
3534
storage_type: Literal['Dataset'],
3635
collection_client: DatasetCollectionClientAsync,
3736
get_resource_client_by_id: Callable[[str], DatasetClientAsync],
37+
api_client: ApifyClientAsync,
3838
configuration: Configuration,
3939
) -> DatasetClientAsync: ...
4040

@@ -46,6 +46,7 @@ async def open_by_alias(
4646
storage_type: Literal['KeyValueStore'],
4747
collection_client: KeyValueStoreCollectionClientAsync,
4848
get_resource_client_by_id: Callable[[str], KeyValueStoreClientAsync],
49+
api_client: ApifyClientAsync,
4950
configuration: Configuration,
5051
) -> KeyValueStoreClientAsync: ...
5152

@@ -57,6 +58,7 @@ async def open_by_alias(
5758
storage_type: Literal['RequestQueue'],
5859
collection_client: RequestQueueCollectionClientAsync,
5960
get_resource_client_by_id: Callable[[str], RequestQueueClientAsync],
61+
api_client: ApifyClientAsync,
6062
configuration: Configuration,
6163
) -> RequestQueueClientAsync: ...
6264

@@ -69,6 +71,7 @@ async def open_by_alias(
6971
KeyValueStoreCollectionClientAsync | RequestQueueCollectionClientAsync | DatasetCollectionClientAsync
7072
),
7173
get_resource_client_by_id: Callable[[str], KeyValueStoreClientAsync | RequestQueueClientAsync | DatasetClientAsync],
74+
api_client: ApifyClientAsync,
7275
configuration: Configuration,
7376
) -> KeyValueStoreClientAsync | RequestQueueClientAsync | DatasetClientAsync:
7477
"""Open storage by alias, creating it if necessary.
@@ -81,6 +84,8 @@ async def open_by_alias(
8184
storage_type: The type of storage to open.
8285
collection_client: The Apify API collection client for the storage type.
8386
get_resource_client_by_id: A callable that takes a storage ID and returns the resource client.
87+
api_client: The Apify API client used for the storage operation. Reused to access the default KVS that
88+
holds the alias mapping, so alias resolution does not spin up its own client.
8489
configuration: Configuration object containing API credentials and settings.
8590
8691
Returns:
@@ -94,6 +99,7 @@ async def open_by_alias(
9499
storage_type=storage_type,
95100
alias=alias,
96101
configuration=configuration,
102+
api_client=api_client,
97103
) as alias_resolver:
98104
storage_id = await alias_resolver.resolve_id()
99105

@@ -111,13 +117,6 @@ async def open_by_alias(
111117
return get_resource_client_by_id(raw_metadata.id)
112118

113119

114-
class _ApiClientCacheKey(NamedTuple):
115-
"""Cache key for `AliasResolver._api_clients` — identifies an `ApifyClientAsync` by its credentials."""
116-
117-
token: str | None
118-
api_url: str | None
119-
120-
121120
class AliasResolver:
122121
"""Class for handling aliases.
123122
@@ -142,21 +141,19 @@ class AliasResolver:
142141
_alias_init_lock: Lock | None = None
143142
"""Lock for creating alias storages. Only one alias storage can be created at the time. Global for all instances."""
144143

145-
_api_clients: ClassVar[dict[_ApiClientCacheKey, ApifyClientAsync]] = {}
146-
"""Cache of Apify API clients keyed by `(token, api_url)`. Reused across instances so that repeated alias
147-
resolution does not create (and leak) a fresh unclosed `ApifyClientAsync` on every call."""
148-
149144
default_storage_key: ClassVar[str] = '__default__'
150145

151146
def __init__(
152147
self,
153148
storage_type: Literal['Dataset', 'KeyValueStore', 'RequestQueue'],
154149
alias: str,
155150
configuration: Configuration,
151+
api_client: ApifyClientAsync,
156152
) -> None:
157153
self._storage_type = storage_type
158154
self._alias = alias
159155
self._configuration = configuration
156+
self._api_client = api_client
160157

161158
async def __aenter__(self) -> AliasResolver:
162159
"""Context manager to prevent race condition in alias creation."""
@@ -184,26 +181,22 @@ async def _get_alias_init_lock(cls) -> Lock:
184181
cls._alias_init_lock = Lock()
185182
return cls._alias_init_lock
186183

187-
@classmethod
188-
async def _get_alias_map(cls, configuration: Configuration) -> dict[str, str]:
184+
async def _get_alias_map(self) -> dict[str, str]:
189185
"""Get the aliases and storage ids mapping from the default kvs.
190186
191-
Mapping is loaded from kvs only once and is shared for all instances of the AliasResolver class.
192-
193-
Args:
194-
configuration: Configuration object to use for accessing the default KVS.
187+
Mapping is loaded from kvs only once and is shared for all instances of the `AliasResolver` class.
195188
196189
Returns:
197190
Map of aliases and storage ids.
198191
"""
199-
if not cls._alias_map_loaded and configuration.is_at_home:
200-
default_kvs_client = await cls._get_default_kvs_client(configuration)
192+
if not AliasResolver._alias_map_loaded and self._configuration.is_at_home:
193+
default_kvs_client = self._get_default_kvs_client()
201194

202-
record = await default_kvs_client.get_record(cls._ALIAS_MAPPING_KEY)
203-
cls._alias_map = record.get('value', {}) if record else {}
204-
cls._alias_map_loaded = True
195+
record = await default_kvs_client.get_record(self._ALIAS_MAPPING_KEY)
196+
AliasResolver._alias_map = record.get('value', {}) if record else {}
197+
AliasResolver._alias_map_loaded = True
205198

206-
return cls._alias_map
199+
return AliasResolver._alias_map
207200

208201
async def resolve_id(self) -> str | None:
209202
"""Get id of the aliased storage.
@@ -223,12 +216,12 @@ async def resolve_id(self) -> str | None:
223216
return storage_id
224217

225218
# Fallback to the mapping saved in the default KVS
226-
return (await self._get_alias_map(self._configuration)).get(self._storage_key, None)
219+
return (await self._get_alias_map()).get(self._storage_key, None)
227220

228221
async def store_mapping(self, storage_id: str) -> None:
229222
"""Add alias and related storage id to the mapping in default kvs and local in-memory mapping."""
230223
# Update in-memory mapping
231-
alias_map = await self._get_alias_map(self._configuration)
224+
alias_map = await self._get_alias_map()
232225
alias_map[self._storage_key] = storage_id
233226

234227
if not self._configuration.is_at_home:
@@ -237,7 +230,7 @@ async def store_mapping(self, storage_id: str) -> None:
237230
)
238231
return
239232

240-
default_kvs_client = await self._get_default_kvs_client(self._configuration)
233+
default_kvs_client = self._get_default_kvs_client()
241234

242235
try:
243236
record = await default_kvs_client.get_record(self._ALIAS_MAPPING_KEY)
@@ -260,24 +253,14 @@ def _storage_key(self) -> str:
260253
]
261254
)
262255

263-
@classmethod
264-
async def _get_default_kvs_client(cls, configuration: Configuration) -> KeyValueStoreClientAsync:
256+
def _get_default_kvs_client(self) -> KeyValueStoreClientAsync:
265257
"""Get a client for the default key-value store.
266258
267-
The underlying `ApifyClientAsync` is cached per `(token, api_url)` and reused across calls, so repeated
268-
alias resolution does not create (and leak) a fresh unclosed client every time.
259+
Derived from the injected `ApifyClientAsync`, so alias resolution shares the same HTTP client (and its
260+
connection pool and event loop affinity) as the storage operation that triggered it, instead of creating
261+
and leaking its own.
269262
"""
270-
if not configuration.default_key_value_store_id:
263+
if not self._configuration.default_key_value_store_id:
271264
raise ValueError("'Configuration.default_key_value_store_id' must be set.")
272265

273-
cache_key = _ApiClientCacheKey(configuration.token, configuration.api_base_url)
274-
apify_client_async = cls._api_clients.get(cache_key)
275-
if apify_client_async is None:
276-
apify_client_async = ApifyClientAsync(
277-
token=configuration.token,
278-
api_url=configuration.api_base_url,
279-
max_retries=8,
280-
)
281-
cls._api_clients[cache_key] = apify_client_async
282-
283-
return apify_client_async.key_value_store(key_value_store_id=configuration.default_key_value_store_id)
266+
return self._api_client.key_value_store(key_value_store_id=self._configuration.default_key_value_store_id)

src/apify/storage_clients/_apify/_api_client_creation.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ def get_resource_client(storage_id: str) -> DatasetClientAsync:
117117
storage_type=storage_type,
118118
collection_client=collection_client,
119119
get_resource_client_by_id=get_resource_client,
120+
api_client=apify_client,
120121
configuration=configuration,
121122
) # ty:ignore[no-matching-overload]
122123

@@ -127,6 +128,7 @@ def get_resource_client(storage_id: str) -> DatasetClientAsync:
127128
storage_type=storage_type,
128129
collection_client=collection_client,
129130
get_resource_client_by_id=get_resource_client,
131+
api_client=apify_client,
130132
configuration=configuration,
131133
) # ty:ignore[no-matching-overload]
132134

tests/e2e/conftest.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,6 @@ def _prepare_test_env() -> None:
8787
# Reset the AliasResolver class state.
8888
AliasResolver._alias_map = {}
8989
AliasResolver._alias_init_lock = None
90-
AliasResolver._api_clients = {}
9190

9291
# Verify that the test environment was set up correctly.
9392
assert os.environ.get(ApifyEnvVars.LOCAL_STORAGE_DIR) == str(tmp_path)

tests/integration/conftest.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,6 @@ def _prepare_test_env() -> None:
7373
# Reset the AliasResolver class state.
7474
AliasResolver._alias_map = {}
7575
AliasResolver._alias_init_lock = None
76-
AliasResolver._api_clients = {}
7776

7877
# Verify that the test environment was set up correctly.
7978
assert os.environ.get(ApifyEnvVars.LOCAL_STORAGE_DIR) == str(tmp_path)

tests/unit/conftest.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,6 @@ def _prepare_test_env() -> None:
7878
AliasResolver._alias_map = {}
7979
AliasResolver._alias_map_loaded = False
8080
AliasResolver._alias_init_lock = None
81-
AliasResolver._api_clients = {}
8281

8382
# Verify that the test environment was set up correctly.
8483
assert os.environ.get(ApifyEnvVars.LOCAL_STORAGE_DIR) == str(tmp_path)

0 commit comments

Comments
 (0)