-
Notifications
You must be signed in to change notification settings - Fork 116
Share one S3 client per connection across result sets and filesystems #1058
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
fc4f8fc
5f46143
f9f2275
85f4c38
243a03b
e254b48
9dae851
07f98ac
5a71b5d
bc0d251
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ | |
|
|
||
| import logging | ||
| import os | ||
| import threading | ||
| import time | ||
| from collections.abc import Callable | ||
| from typing import ( | ||
|
|
@@ -132,6 +133,7 @@ def __init__( | |
| on_start_query_execution: Callable[[str], None] | None = ..., | ||
| on_poll: OnPollCallback | None = ..., | ||
| glue_metadata_fallback: bool = ..., | ||
| s3_config: Config | None = ..., | ||
| **kwargs, | ||
| ) -> None: ... | ||
|
|
||
|
|
@@ -165,6 +167,7 @@ def __init__( | |
| on_start_query_execution: Callable[[str], None] | None = ..., | ||
| on_poll: OnPollCallback | None = ..., | ||
| glue_metadata_fallback: bool = ..., | ||
| s3_config: Config | None = ..., | ||
| **kwargs, | ||
| ) -> None: ... | ||
|
|
||
|
|
@@ -197,6 +200,7 @@ def __init__( | |
| on_start_query_execution: Callable[[str], None] | None = None, | ||
| on_poll: OnPollCallback | None = None, | ||
| glue_metadata_fallback: bool = True, | ||
| s3_config: Config | None = None, | ||
| **kwargs, | ||
| ) -> None: | ||
| """Initialize a new Athena database connection. | ||
|
|
@@ -243,6 +247,8 @@ def __init__( | |
| answer a throttled table-metadata, table-listing or | ||
| database-listing request from the AWS Glue Data Catalog before | ||
| retrying it. Defaults to True. | ||
| s3_config: Botocore Config options for the S3 client only, such as | ||
| ``max_pool_connections``. They are merged over ``config``. | ||
| **kwargs: Additional arguments passed to boto3 Session and client. | ||
|
|
||
| Raises: | ||
|
|
@@ -331,16 +337,16 @@ def __init__( | |
| **self._session_kwargs, | ||
| ) | ||
|
|
||
| if not self.config.user_agent_extra or ( | ||
| pyathena.user_agent_extra not in self.config.user_agent_extra | ||
| ): | ||
| self.config.user_agent_extra = ( | ||
| f"{pyathena.user_agent_extra}" | ||
| f"{' ' + self.config.user_agent_extra if self.config.user_agent_extra else ''}" | ||
| ) | ||
| self._add_user_agent(self.config) | ||
| self.s3_config: Config = self.config.merge(s3_config) if s3_config else self.config | ||
| self._add_user_agent(self.s3_config) | ||
| self._client = self._session.client( | ||
| "athena", region_name=self.region_name, config=self.config, **self._client_kwargs | ||
| ) | ||
| # Built on first use, once per connection even when several threads | ||
| # need it at the same time. | ||
| self._s3_client_lock = threading.Lock() | ||
| self._s3_client: BaseClient | None = None | ||
| self._converter = converter | ||
| self._formatter = formatter if formatter else DefaultParameterFormatter() | ||
| self._retry_config = retry_config if retry_config else RetryConfig() | ||
|
|
@@ -356,6 +362,19 @@ def __init__( | |
| self._session, self.region_name, self.config, self._client_kwargs | ||
| ) | ||
|
|
||
| @staticmethod | ||
| def _add_user_agent(config: Config) -> None: | ||
| """Add PyAthena's user agent to a botocore config unless it has it. | ||
|
|
||
| Args: | ||
| config: The config to update in place. | ||
| """ | ||
| if not config.user_agent_extra or pyathena.user_agent_extra not in config.user_agent_extra: | ||
| config.user_agent_extra = ( | ||
| f"{pyathena.user_agent_extra}" | ||
| f"{' ' + config.user_agent_extra if config.user_agent_extra else ''}" | ||
| ) | ||
|
|
||
| def _assume_role( | ||
| self, | ||
| profile_name: str | None, | ||
|
|
@@ -477,6 +496,18 @@ def _client_kwargs(self) -> dict[str, Any]: | |
| """ | ||
| return {k: v for k, v in self._kwargs.items() if k in self._CLIENT_PASSING_ARGS} | ||
|
|
||
| @property | ||
| def _s3_client_kwargs(self) -> dict[str, Any]: | ||
| """Get client keyword arguments for S3 client creation. | ||
|
|
||
| Returns: | ||
| The client keyword arguments without Athena's ``endpoint_url`` | ||
| and ``api_version``. | ||
| """ | ||
| return { | ||
| k: v for k, v in self._client_kwargs.items() if k not in ("endpoint_url", "api_version") | ||
| } | ||
|
|
||
| @property | ||
| def session(self) -> Session: | ||
| """Get the boto3 session used for AWS API calls. | ||
|
|
@@ -495,6 +526,24 @@ def client(self) -> BaseClient: | |
| """ | ||
| return self._client | ||
|
|
||
| @property | ||
| def s3_client(self) -> BaseClient: | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Self-review round one (behavior and implementation): FINDINGS (1, repaired) Base a4cf604, head 3d6f7b8 (full diff, 10 files). Covered:
Finding: the Spark comment
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Independent review (relayed): CLEAN, static review.
|
||
| """The S3 client shared by the connection's result sets, filesystems and Spark cursors. | ||
|
|
||
| It is built on first use from the connection's session, region, | ||
| ``s3_config`` and client arguments, except Athena's ``endpoint_url`` | ||
| and ``api_version``. | ||
| """ | ||
| with self._s3_client_lock: | ||
| if self._s3_client is None: | ||
| self._s3_client = self._session.client( | ||
| "s3", | ||
| region_name=self.region_name, | ||
| config=self.s3_config, | ||
| **self._s3_client_kwargs, | ||
| ) | ||
| return self._s3_client | ||
|
|
||
| @property | ||
| def retry_config(self) -> RetryConfig: | ||
| """Get the retry configuration for AWS API calls. | ||
|
|
@@ -587,14 +636,19 @@ def cursor( | |
| def close(self) -> None: | ||
| """Close the connection. | ||
|
|
||
| Closes the database connection. This method is provided for DB API 2.0 | ||
| compatibility. Since Athena connections are stateless, this method | ||
| currently does not perform any actual cleanup operations. | ||
| Closes the network connections of the Athena client and of the Glue and | ||
| S3 clients if they were built. A client used after this opens new | ||
| network connections. | ||
|
|
||
| Note: | ||
| This method is called automatically when using the connection | ||
| as a context manager (with statement). | ||
| """ | ||
| self._client.close() | ||
| self._glue.close() | ||
| with self._s3_client_lock: | ||
| if self._s3_client is not None: | ||
| self._s3_client.close() | ||
|
|
||
| def commit(self) -> None: | ||
| """Commit any pending transaction. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -234,9 +234,10 @@ def __init__( | |
| """Create a filesystem for Amazon S3. | ||
|
|
||
| Args: | ||
| connection: A PyAthena connection whose session, region, config and | ||
| retry policy the S3 client uses. Without one, the client is built | ||
| from s3fs-compatible arguments in ``kwargs``. | ||
| connection: A PyAthena connection whose S3 client | ||
| (``Connection.s3_client``) and retry policy the filesystem uses. | ||
| Without one, the client is built from s3fs-compatible arguments | ||
| in ``kwargs``. | ||
| default_block_size: The block size for reads and writes; defaults to | ||
| ``DEFAULT_BLOCK_SIZE``. | ||
| default_cache_type: The fsspec cache type for reads; defaults to | ||
|
|
@@ -259,12 +260,7 @@ def __init__( | |
| """ | ||
| super().__init__(*args, **kwargs) | ||
| if connection: | ||
| client = connection.session.client( | ||
| "s3", | ||
| region_name=connection.region_name, | ||
| config=connection.config, | ||
| **connection._client_kwargs, | ||
| ) | ||
| client = connection.s3_client | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rebase record: rebased from base a4cf604 / head 7c394d1 onto 8bbf8cb (master after #1044, #1055, #1056, #1057, #1060); new head bc0d251.
|
||
| retry_config = connection.retry_config | ||
| else: | ||
| client = self._get_client_compatible_with_s3fs(**kwargs) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -259,7 +259,7 @@ def to_sql( | |
|
|
||
| bucket_name, key_prefix = parse_output_location(location) | ||
| bucket = conn.session.resource( | ||
| "s3", region_name=conn.region_name, **conn._client_kwargs | ||
| "s3", region_name=conn.region_name, **conn._s3_client_kwargs | ||
| ).Bucket(bucket_name) | ||
| cursor = conn.cursor() | ||
|
|
||
|
|
@@ -298,7 +298,7 @@ def to_sql( | |
| futures: list[concurrent.futures.Future[Any]] = [] | ||
| session_kwargs = deepcopy(conn._session_kwargs) | ||
| session_kwargs.update({"profile_name": conn.profile_name}) | ||
| client_kwargs = deepcopy(conn._client_kwargs) | ||
| client_kwargs = deepcopy(conn._s3_client_kwargs) | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Self-review round one (behavior), follow-up scope: CLEAN Scope: 041913b..HEAD (3f1f48c test isolation, cacf8d8 Polars docs, 8ec954b
|
||
| client_kwargs.update({"region_name": conn.region_name}) | ||
| partition_prefixes = [] | ||
| if partitions: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -306,6 +306,8 @@ def _create_connect_args(self, url: URL) -> dict[str, Any]: | |
| with contextlib.suppress(ValueError): | ||
| verify = bool(strtobool(verify)) | ||
| opts.update({"verify": verify}) | ||
| if "use_ssl" in opts: | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Self-review round two (claims, callers), follow-up scope: CLEAN Scope: 041913b..HEAD.
|
||
| opts.update({"use_ssl": bool(strtobool(opts["use_ssl"]))}) | ||
| if "duration_seconds" in opts: | ||
| opts.update({"duration_seconds": int(opts["duration_seconds"])}) | ||
| if "poll_interval" in opts: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Self-review round two (claims, callers, operations): FINDINGS (3, repaired)
Base a4cf604, head 3c666d1 (full pass: PR body, commit messages, docstrings,
docs/usage.md, related docs).Claims checked:
fetchall()).botocore.endpoint.MAX_POOL_CONNECTIONS == 10(botocore 1.43.102).PoolManagersets noblock, and urllib3 2.8.0_put_conn()closes and discards the connection when the queue is full.close()":URLLib3Session.close()only clears the pool managers; a HEAD request afterclose()succeeded locally.AthenaArrowResultSet._read_csv()/_read_parquet()useself._fs(pyarrow), and only_get_content_length()/_read_data_manifest()use the shared client.Cursor/AsyncCursoruseAthenaResultSet,AioCursorusesAthenaAioResultSet; neither calls the S3 helpers (live test forCursor).test_executemanyskip: unconditional@pytest.mark.skip, so it is skipped on master too.config's retry settings unlesss3_configoverrides them, andretry_api_callstill wraps the result-set requests.Findings and repairs (543996a):
s3_configwas inserted afterconfig, which movedresult_reuse_enableand the later parameters by one position for positional callers. It is now the last parameter, asglue_metadata_fallbackwas added.urllib3.connectionpool, not a Python warning. Reworded indocs/usage.md:774and the PR body.docs/filesystem.mddid not say that a filesystem built from a connection uses its S3 client; it now links to the new section (plain page link, sincemyst_heading_anchorsis not enabled).After the repairs:
just lint,just docs lint,tests/pyathena/test_connection.py(48 passed) andtests/pyathena/spark/test_common.py(86 passed).