From 69c14e61ba103f894d8763800f8b81eca7db7d77 Mon Sep 17 00:00:00 2001 From: karezche <64801825+karenc-bq@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:47:45 -0700 Subject: [PATCH] fix: build the host list provider from a verified database dialect --- .../aio/aurora_connection_tracker.py | 8 + .../aio/dialect_utils.py | 27 + .../aio/gdb_failover_plugin.py | 4 +- .../aio/host_list_provider.py | 162 ++++- .../aio/plugin_service.py | 254 ++++++-- .../aio/read_write_splitting_plugin.py | 14 +- aws_advanced_python_wrapper/aio/wrapper.py | 160 ++--- .../database_dialect.py | 570 ++++++++++-------- .../dialect_verifier.py | 269 +++++++++ .../failover_v2_plugin.py | 2 +- .../host_list_provider.py | 11 + .../limitless_plugin.py | 4 +- aws_advanced_python_wrapper/plugin_service.py | 26 +- ...dvanced_python_wrapper_messages.properties | 42 +- .../container/test_failover_performance.py | 2 +- .../test_failover_performance_async.py | 2 +- .../test_aio_aurora_connection_tracker.py | 42 ++ .../unit/test_aio_dialect_provider_logging.py | 233 +++++++ tests/unit/test_aio_host_list_provider.py | 54 +- .../test_aio_host_list_provider_autodetect.py | 175 ++++-- tests/unit/test_aio_plugin_factory.py | 6 +- tests/unit/test_aio_plugin_service_extras.py | 563 ++++++++++++++++- tests/unit/test_aio_wrapper.py | 69 ++- tests/unit/test_dialect.py | 175 ++++-- .../test_multi_az_rds_host_list_provider.py | 26 + 25 files changed, 2254 insertions(+), 646 deletions(-) create mode 100644 aws_advanced_python_wrapper/dialect_verifier.py create mode 100644 tests/unit/test_aio_dialect_provider_logging.py diff --git a/aws_advanced_python_wrapper/aio/aurora_connection_tracker.py b/aws_advanced_python_wrapper/aio/aurora_connection_tracker.py index 93e287eeb..0361d3524 100644 --- a/aws_advanced_python_wrapper/aio/aurora_connection_tracker.py +++ b/aws_advanced_python_wrapper/aio/aurora_connection_tracker.py @@ -602,7 +602,15 @@ def _parse_alias(alias: str) -> tuple: ``[::1]:5432`` split correctly on the LAST colon. Falls back to port 5432 when the alias has no colon (or the port part isn't numeric). + + The trailing slash is stripped first: host-list-change keys are + ``HostInfo.url``, which is ``host:port/``. Without this, ``int()`` + fails on ``"3306/"`` and the port silently became 5432, so a + rebuilt key never matched the tracked one and a demoted MySQL + writer's connections were never invalidated. """ + if alias.endswith("/"): + alias = alias[:-1] host_part, sep, port_part = alias.rpartition(":") if not sep: # No colon -> treat entire alias as host, port defaults. diff --git a/aws_advanced_python_wrapper/aio/dialect_utils.py b/aws_advanced_python_wrapper/aio/dialect_utils.py index d01e93bb9..b2af13d40 100644 --- a/aws_advanced_python_wrapper/aio/dialect_utils.py +++ b/aws_advanced_python_wrapper/aio/dialect_utils.py @@ -171,6 +171,33 @@ async def _execute_scalar_query( except Exception: # noqa: BLE001 - cursor close best-effort pass + @staticmethod + async def fetch_one(conn: Any, query: str) -> Optional[tuple]: + """Run the provided query and fetch the first row. + """ + return await AsyncDialectUtils._execute_scalar_query(conn, query) + + @staticmethod + async def fetch_all(conn: Any, query: str) -> list: + """Run the provided query and fetch all rows. Returns an empty list on no results. + """ + cursor_ret = conn.cursor() + cursor = await cursor_ret if asyncio.iscoroutine(cursor_ret) else cursor_ret + try: + await cursor.execute(query) + return list(await cursor.fetchall()) + finally: + # Call close on the curosor if it has a close method. + # Some async DBAPI implementations may not have a close method on their cursor. + close = getattr(cursor, "close", None) + if close is not None: + try: + result = close() + if asyncio.iscoroutine(result): + await result + except Exception: # noqa: BLE001 - cursor close best-effort + pass + @staticmethod async def _execute_is_reader_query( conn: Any, diff --git a/aws_advanced_python_wrapper/aio/gdb_failover_plugin.py b/aws_advanced_python_wrapper/aio/gdb_failover_plugin.py index b878473d3..5469b63db 100644 --- a/aws_advanced_python_wrapper/aio/gdb_failover_plugin.py +++ b/aws_advanced_python_wrapper/aio/gdb_failover_plugin.py @@ -260,8 +260,8 @@ async def _failover_with_mode( # deadline (validated on Aurora: 2592 reconnects to the stale # host over 5 min, then timeout). # STRICT_WRITER means "reconnect to the new writer" -- exactly - # failover_v2's job. (Requires the real topology provider, i.e. - # gdb_failover present in wrapper._TOPOLOGY_REQUIRING_PLUGINS.) + # failover_v2's job. (Requires a real topology provider, which + # the Global Aurora dialects supply.) await self._failover_writer( self._plugin_service.all_hosts, driver_dialect, deadline, None) case GdbFailoverMode.STRICT_HOME_READER: diff --git a/aws_advanced_python_wrapper/aio/host_list_provider.py b/aws_advanced_python_wrapper/aio/host_list_provider.py index 38345699c..06d5b45b0 100644 --- a/aws_advanced_python_wrapper/aio/host_list_provider.py +++ b/aws_advanced_python_wrapper/aio/host_list_provider.py @@ -141,6 +141,13 @@ def initial_connection_host_info(self, value: HostInfo) -> None: def is_static_host_list_provider(self) -> bool: ... + @property + def is_dialect_confirmed(self) -> bool: + ... + + def get_target_driver_func(self) -> Optional[Callable]: + ... + class AsyncStaticHostListProvider: """Static host list -- one host built from connection props. @@ -221,7 +228,9 @@ def __init__( cluster_id: Optional[str] = None, default_port: int = 5432, monitor_connection_factory: Optional[ - Callable[[], Awaitable[Any]]] = None) -> None: + Callable[[], Awaitable[Any]]] = None, + provider_service: Optional[AsyncHostListProviderService] = None) -> None: + self._provider_service = provider_service self._props = props self._driver_dialect = driver_dialect self._topology_query = topology_query @@ -247,6 +256,22 @@ def __init__( # the wrapper once the plugin service exists -- see # aio/wrapper.py and cluster_topology_monitor.build_probe_host. self._probe_host: Optional[Callable[..., Any]] = None + self._instance_host_pattern, self._instance_pattern_port = \ + self._resolve_instance_pattern() + # Resolve initial host + port_raw = props.get("port") + self._initial_hosts: Topology = ( + HostInfo( + host=props.get("host", ""), + port=int(port_raw) if port_raw is not None else default_port, + role=HostRole.WRITER),) + + def _is_dialect_confirmed(self) -> bool: + """Whether the database dialect is finalized and whether topology may be queried yet. + """ + if self._provider_service is None: + return True + return self._provider_service.is_dialect_confirmed def set_probe_host(self, probe_host: Callable[..., Any]) -> None: """Arm panic-mode writer discovery on the (future) topology monitor. @@ -258,25 +283,6 @@ def set_probe_host(self, probe_host: Callable[..., Any]) -> None: """ self._probe_host = probe_host - def reconfigure_topology_query( - self, topology_query: str, default_port: Optional[int] = None) -> None: - """Re-point the provider at a (post-connect) resolved dialect's query. - - The provider is built BEFORE the connection exists, so an - instance-endpoint MySQL connection starts with the PostgreSQL-default - topology query + port (the ctor default). Once the database dialect is - resolved/upgraded to the Aurora MySQL dialect we swap in its topology - query and the MySQL port so discovery actually works. Clears the cache - so the next refresh re-probes with the new query (any cached PG-query - result is stale/empty). - """ - if topology_query: - self._topology_query = topology_query - if default_port: - self._default_port = default_port - self._topology_cache = None - self._last_refresh_ns = 0 - @staticmethod def _derive_cluster_id(props: Properties) -> str: # Only treat `cluster_id` as explicit when present in the props dict; @@ -325,6 +331,11 @@ async def force_refresh(self, connection: Optional[Any] = None) -> Topology: return self._topology_cache return () + if not self._is_dialect_confirmed(): + # No topology query and no monitor until the dialect is + # confirmed. Report the host from the connection properties in the meantime. + return self._topology_cache or self._initial_hosts + async with self._refresh_lock: monitor = self._get_or_create_monitor() if monitor is None: @@ -366,6 +377,10 @@ async def force_monitoring_refresh( querying through a caller connection. Falls back to :meth:`force_refresh` when no monitor can be built (static dialects). """ + if not self._is_dialect_confirmed(): + # No topology query and no monitor until the dialect is + # confirmed. Report the host from the connection properties in the meantime. + return self._topology_cache or self._initial_hosts monitor = self._get_or_create_monitor() if monitor is None: return await self.force_refresh(self._last_conn) @@ -507,7 +522,7 @@ def _rows_to_topology(self, rows: List[tuple]) -> Topology: # The instance host pattern may carry an explicit ``:port`` (proxied # test endpoints do); use it for every topology host. See # :meth:`_resolve_instance_pattern`. - _, pattern_port = self._resolve_instance_pattern() + pattern_port = self._instance_pattern_port port = pattern_port if pattern_port is not None else self._default_port # Dedup by resolved host (a row may repeat), matching sync # AuroraTopologyUtils._process_query_results (:603-606). @@ -573,6 +588,11 @@ def _rows_to_topology(self, rows: List[tuple]) -> Topology: def _resolve_instance_pattern(self) -> Tuple[Optional[str], Optional[int]]: """Resolve the instance host pattern and its explicit port (if any). + Called once, from ``__init__``; read afterwards through + ``_instance_host_pattern`` / ``_instance_pattern_port``. Keep it that + way -- it validates (and so can raise) and builds ``RdsUtils``, which + is why it does not belong on the per-row path. + Returns ``(host_pattern_with_'?', port)``; ``port`` is ``None`` when the pattern carries no explicit ``:port`` suffix. @@ -645,7 +665,7 @@ def _host_from_server_id(self, server_id: str) -> str: ID yields a non-resolvable name, so every reader/failover connect to a topology host fails with "failed to resolve host ''". """ - host_pattern, _ = self._resolve_instance_pattern() + host_pattern = self._instance_host_pattern if host_pattern: return host_pattern.replace("?", server_id) return server_id @@ -708,6 +728,7 @@ def __init__( monitor_connection_factory: Optional[ Callable[[], Awaitable[Any]]] = None, writer_host_column_index: int = 0, + provider_service: Optional[AsyncHostListProviderService] = None, ) -> None: super().__init__( props=props, @@ -716,6 +737,7 @@ def __init__( cluster_id=cluster_id, default_port=default_port, monitor_connection_factory=monitor_connection_factory, + provider_service=provider_service, ) self._writer_host_query = writer_host_query self._host_id_query = host_id_query @@ -859,6 +881,7 @@ def __init__( default_port: int = 5432, monitor_connection_factory: Optional[ Callable[[], Awaitable[Any]]] = None, + provider_service: Optional[AsyncHostListProviderService] = None, ) -> None: super().__init__( props=props, @@ -867,6 +890,7 @@ def __init__( cluster_id=cluster_id, default_port=default_port, monitor_connection_factory=monitor_connection_factory, + provider_service=provider_service, ) if instance_templates_by_region is None: raw = WrapperProperties.GLOBAL_CLUSTER_INSTANCE_HOST_PATTERNS.get( @@ -954,3 +978,97 @@ def _rows_to_topology(self, rows: List[tuple]) -> Topology: # Writer first, readers after -- matches the other async providers. hosts.sort(key=lambda h: 0 if h.role == HostRole.WRITER else 1) return tuple(hosts) + + +def build_monitor_connection_factory( + provider_service: AsyncHostListProviderService, + props: Properties) -> Callable[[], Awaitable[Any]]: + """Build the factory the topology monitor uses for its own connection. + """ + from aws_advanced_python_wrapper.utils.properties import (Properties, + PropertiesUtils) + + async def _connect() -> Any: + target_func = provider_service.get_target_driver_func() + if target_func is None: + raise AwsWrapperError( + Messages.get_formatted( + "HostListProvider.MonitorConnectionNoTargetDriverFunc", + type(provider_service).__name__)) + host_info = (provider_service.initial_connection_host_info + or provider_service.current_host_info) + if host_info is None: + raise AwsWrapperError( + Messages.get_formatted( + "HostListProvider.MonitorConnectionNoSeedHost", + type(provider_service).__name__)) + monitoring_props = PropertiesUtils.create_topology_monitoring_properties( + Properties(dict(props))) + return await provider_service.driver_dialect.connect( + host_info, monitoring_props, target_func) + + return _connect + + +def create_static_provider( + provider_service: AsyncHostListProviderService, + props: Properties) -> AsyncHostListProvider: + """Supplier body for dialects with no topology: a single seed host.""" + return AsyncStaticHostListProvider(props) + + +def create_aurora_provider( + provider_service: AsyncHostListProviderService, + props: Properties, + *, + topology_query: str, + default_port: int) -> AsyncHostListProvider: + """Supplier body for the Aurora topology dialects.""" + return AsyncAuroraHostListProvider( + props, + provider_service.driver_dialect, + topology_query=topology_query, + default_port=default_port, + monitor_connection_factory=build_monitor_connection_factory( + provider_service, props), + provider_service=provider_service) + + +def create_global_aurora_provider( + provider_service: AsyncHostListProviderService, + props: Properties, + *, + topology_query: str, + default_port: int) -> AsyncHostListProvider: + """Supplier body for the Aurora Global Database dialects.""" + return AsyncGlobalAuroraHostListProvider( + props, + provider_service.driver_dialect, + topology_query=topology_query, + default_port=default_port, + monitor_connection_factory=build_monitor_connection_factory( + provider_service, props), + provider_service=provider_service) + + +def create_multi_az_provider( + provider_service: AsyncHostListProviderService, + props: Properties, + *, + writer_host_query: str, + topology_query: str, + host_id_query: str, + default_port: int, + writer_host_column_index: int = 0) -> AsyncHostListProvider: + """Supplier body for the RDS Multi-AZ DB cluster dialects.""" + return AsyncMultiAzHostListProvider( + props, + provider_service.driver_dialect, + writer_host_query=writer_host_query, + topology_query=topology_query, + host_id_query=host_id_query, + default_port=default_port, + writer_host_column_index=writer_host_column_index, + monitor_connection_factory=build_monitor_connection_factory( + provider_service, props), + provider_service=provider_service) diff --git a/aws_advanced_python_wrapper/aio/plugin_service.py b/aws_advanced_python_wrapper/aio/plugin_service.py index ba4628192..cbe495983 100644 --- a/aws_advanced_python_wrapper/aio/plugin_service.py +++ b/aws_advanced_python_wrapper/aio/plugin_service.py @@ -55,7 +55,8 @@ AsyncPluginManager from aws_advanced_python_wrapper.allowed_and_blocked_hosts import \ AllowedAndBlockedHosts - from aws_advanced_python_wrapper.database_dialect import DatabaseDialect + from aws_advanced_python_wrapper.database_dialect import ( + DatabaseDialect, DatabaseDialectManager) from aws_advanced_python_wrapper.hostinfo import HostInfo from aws_advanced_python_wrapper.utils.properties import Properties from aws_advanced_python_wrapper.utils.telemetry.telemetry import \ @@ -96,6 +97,16 @@ def database_dialect(self) -> Optional[DatabaseDialect]: def database_dialect(self, value: Optional[DatabaseDialect]) -> None: ... + @property + def is_dialect_confirmed(self) -> bool: + """Whether the database dialect is confirmed via a live connection or user configuration and can't be updated. + """ + ... + + @is_dialect_confirmed.setter + def is_dialect_confirmed(self, value: bool) -> None: + ... + def set_availability( self, host_aliases: FrozenSet[str], @@ -384,9 +395,11 @@ def __init__( self._props: Properties = props self._driver_dialect: AsyncDriverDialect = driver_dialect self._database_dialect: Optional[DatabaseDialect] = None - # True once update_database_dialect has made a definitive decision - # (sync parity: DatabaseDialectManager can_update going False). - self._database_dialect_settled: bool = False + # True once the dialect is finalized. Host list providers + # wait on this before querying topology or starting a topology + # monitor. + self._is_dialect_confirmed: bool = False + self._database_dialect_manager: Optional[DatabaseDialectManager] = None self._exception_manager: ExceptionManager = ExceptionManager() self._plugin_manager: Optional[AsyncPluginManager] = None self._host_list_provider: Optional[AsyncHostListProvider] = None @@ -477,6 +490,23 @@ def database_dialect(self) -> Optional[DatabaseDialect]: def database_dialect(self, value: Optional[DatabaseDialect]) -> None: self._database_dialect = value + @property + def is_dialect_confirmed(self) -> bool: + return self._is_dialect_confirmed + + @is_dialect_confirmed.setter + def is_dialect_confirmed(self, value: bool) -> None: + self._is_dialect_confirmed = value + + @property + def database_dialect_manager(self) -> Optional[DatabaseDialectManager]: + return self._database_dialect_manager + + @database_dialect_manager.setter + def database_dialect_manager( + self, value: Optional[DatabaseDialectManager]) -> None: + self._database_dialect_manager = value + def is_network_exception( self, error: Optional[Exception] = None, @@ -498,19 +528,38 @@ def is_read_only_connection_exception( return self._exception_manager.is_read_only_connection_exception( self._database_dialect, error=error, sql_state=sql_state) + @staticmethod + def _availability_key(value: str) -> str: + """Normalize a host alias or url to one availability cache key. + + ``HostInfo.url`` is ``as_alias()`` plus a trailing slash, so writing + under aliases and reading under urls never matched and every mark + was discarded. + """ + return value[:-1] if value.endswith("/") else value + def set_availability( self, host_aliases: FrozenSet[str], availability: HostAvailability) -> None: for alias in host_aliases: AsyncPluginServiceImpl._host_availability_expiring_cache.put( - alias, + AsyncPluginServiceImpl._availability_key(alias), availability, AsyncPluginServiceImpl._HOST_AVAILABILITY_EXPIRATION_NANO, ) + # Also mark the live hosts, not just the cache. Host selection reads + # HostInfo.get_availability() directly, so a cache-only write would + # not reach it until the topology was rebuilt. + keys = {AsyncPluginServiceImpl._availability_key(a) for a in host_aliases} + for host in self._all_hosts: + if keys & {AsyncPluginServiceImpl._availability_key(a) + for a in host.as_aliases()}: + host.set_availability(availability) def get_availability(self, host_url: str) -> Optional[HostAvailability]: - return AsyncPluginServiceImpl._host_availability_expiring_cache.get(host_url) + return AsyncPluginServiceImpl._host_availability_expiring_cache.get( + AsyncPluginServiceImpl._availability_key(host_url)) @property def plugin_manager(self) -> Optional[AsyncPluginManager]: @@ -612,64 +661,138 @@ async def update_database_dialect(self, connection: Any) -> None: Async parity for sync ``PluginServiceImpl.update_dialect`` (plugin_service.py:538), invoked from the terminal plugin's connect - (sync default_plugin.py:82) so every OUTER plugin's post-connect logic - already sees the corrected dialect. The async layer previously ran - this upgrade only AFTER the whole connect pipeline - (``_upgrade_database_dialect_after_connect`` in aio/wrapper.py), which - left plugin connect hooks working with the pattern-guessed dialect -- - on an Aurora MySQL *instance* endpoint that guess is - ``RdsMysqlDialect`` (no topology query; ``@@read_only`` role probe - that does not flip on Aurora failover), so hook-time topology/role - operations failed silently (the root asymmetry behind the - connection-tracker's pin and exception-classification defenses). - - Only MySQL needs a live probe: the PG pattern guess is already - Aurora-correct for every endpoint form. Settles after a definitive - probe (mirrors sync ``can_update`` going False); a transient probe - failure leaves it unsettled so the next connect retries -- sync runs - update_dialect on every connect for the same reason. + (sync default_plugin.py:82) so every OUTER plugin's post-connect + logic already sees the corrected dialect. + + Before a connection exists the dialect is only a guess made from the + endpoint pattern. On an Aurora MySQL *instance* endpoint that guess + is ``RdsMysqlDialect``: no topology query, and an ``@@read_only`` + role probe that does not flip on Aurora failover, so hook-time + topology and role operations failed silently (the root asymmetry + behind the connection-tracker's pin and the exception-classification + defenses). Live verification replaces it with ``AuroraMysqlDialect``, and + the host list provider is rebuilt from the new dialect, because the + provider carries that dialect's topology SQL. + + Marks the dialect confirmed once it can no longer change, which is + what releases the host list providers to query topology at all. + + Walks the full ``dialect_update_candidates`` chain through the + dialect manager, so both engines upgrade to the same dialect from + the same connection -- including the chained upgrades a single rule set + misses, such as Aurora MySQL to Global Aurora MySQL. + + Deviation from sync: a verification failure is swallowed and the dialect + left unconfirmed, where sync propagates. The next connect then + retries rather than freezing a wrong guess. """ - if self._database_dialect_settled: + if self._is_dialect_confirmed: return - driver_dialect = self._driver_dialect - if getattr(driver_dialect, "_driver_name", None) != "aiomysql": - self._database_dialect_settled = True + + manager = self._database_dialect_manager + if manager is None: + # No manager means nothing resolved the initial guess, so there + # is no candidate chain to walk. Settle rather than retry + # forever: a dialect supplied directly (tests, a custom dialect + # assigned onto the service) is the user's answer. + self._is_dialect_confirmed = True return - from aws_advanced_python_wrapper.aio.host_list_provider import \ - AsyncAuroraHostListProvider - from aws_advanced_python_wrapper.database_dialect import ( - AuroraMysqlDialect, DatabaseDialectManager, DialectCode, - MysqlDatabaseDialect) - - database_dialect = self._database_dialect - # A base/RDS MySQL dialect needs a live probe; a cluster-endpoint - # connect already resolved to AuroraMysqlDialect by pattern and skips - # the probe (but still needs the provider wired below). - if (not isinstance(database_dialect, AuroraMysqlDialect) - and isinstance(database_dialect, MysqlDatabaseDialect)): + + original_dialect = self._database_dialect + host_info = self._initial_connection_host_info + url = host_info.url if host_info is not None else "" + try: + database_dialect = await manager.query_for_dialect_async( + url, host_info, connection, self._driver_dialect) + except Exception as ex: # noqa: BLE001 - keep initial dialect; retry next connect + # Deliberately left unconfirmed: a transient verification failure must + # not freeze the guess, so the next connect retries. + logger.debug( + "AsyncPluginService.DatabaseDialectUnconfirmed", + f"{type(original_dialect).__name__} ({ex})") + return + + self._database_dialect = database_dialect + self._is_dialect_confirmed = True + if original_dialect is not database_dialect: + logger.debug( + "AsyncPluginService.DatabaseDialectUpdated", + type(original_dialect).__name__, + type(database_dialect).__name__) + await self._update_host_list_provider(connection) + else: + logger.debug( + "AsyncPluginService.DatabaseDialectConfirmed", + type(database_dialect).__name__) + + # The provider attributes that come from the dialect. Two + # providers of the same class agreeing on all of these are + # interchangeable, because everything else a supplier passes (props, + # driver dialect, provider service, monitor factory) comes from this + # plugin service and is identical either way. + # + # Kept in step with the suppliers by + # test_provider_identity_covers_every_dialect_supplied_argument, which + # fails if a supplier gains an argument this list does not name. + _PROVIDER_DIALECT_ATTRS = ( + "_topology_query", + "_default_port", + "_writer_host_query", + "_host_id_query", + "_writer_host_column_index", + ) + + @staticmethod + def _provider_identity(provider: Any) -> tuple: + """What makes a provider the right one for a dialect. + + ``getattr`` with a default rather than direct access: the static + (no-topology) provider carries none of these, and only the Multi-AZ + providers carry the writer/host-id ones. + """ + return (type(provider),) + tuple( + getattr(provider, attr, None) + for attr in AsyncPluginServiceImpl._PROVIDER_DIALECT_ATTRS) + + async def _update_host_list_provider(self, connection: Any) -> None: + """Replace the host list provider when the dialect calls for a + different one. + """ + dialect = self._database_dialect + if dialect is None: + return + + supplier = dialect.get_async_host_list_provider_supplier() + replacement = supplier(self, self._props) + previous = self._host_list_provider + + if previous is not None and \ + self._provider_identity(previous) == self._provider_identity(replacement): + logger.debug( + "AsyncPluginService.HostListProviderUnchanged", + type(dialect).__name__, + type(previous).__name__) + return + + if previous is not None: try: - async with connection.cursor() as cur: - await cur.execute("SHOW VARIABLES LIKE 'aurora_version'") - # fetchall (not fetchone) so the result set is fully - # drained -- a partially-read result leaves the shared - # aiomysql connection dirty and the app's next query reads - # the leftover row. - is_aurora = len(await cur.fetchall()) > 0 - except Exception: # noqa: BLE001 - keep initial dialect; retry next connect - return - if is_aurora: - aurora = DatabaseDialectManager._known_dialects_by_code.get( - DialectCode.AURORA_MYSQL) - if aurora is not None: - database_dialect = aurora - self._database_dialect = aurora - - if (isinstance(database_dialect, AuroraMysqlDialect) - and isinstance(self._host_list_provider, - AsyncAuroraHostListProvider)): - self._host_list_provider.reconfigure_topology_query( - database_dialect.topology_query, default_port=3306) - self._database_dialect_settled = True + await previous.stop() + except Exception as ex: # noqa: BLE001 - teardown is best-effort + logger.debug( + "AsyncPluginService.HostListProviderStopFailed", ex) + # Carry the panic-mode probe across. + probe_host = getattr(previous, "_probe_host", None) + set_probe = getattr(replacement, "set_probe_host", None) + if probe_host is not None and callable(set_probe): + set_probe(probe_host) + + self._host_list_provider = replacement + logger.debug( + "AsyncPluginService.HostListProviderReplaced", + type(dialect).__name__, + "" if previous is None else type(previous).__name__, + type(replacement).__name__) + await self.force_refresh_host_list(connection) async def identify_connection( self, connection: Optional[Any] = None) -> Optional[HostInfo]: @@ -795,9 +918,18 @@ def _update_host_availability(self, hosts: Tuple[HostInfo, ...]) -> None: # (topology queries return AVAILABLE-by-default hosts). Mirrors sync # PluginServiceImpl._update_host_availability. for host in hosts: - availability = AsyncPluginServiceImpl._host_availability_expiring_cache.get(host.url) + availability = self.get_availability(host.url) if availability: host.set_availability(availability) + elif host.get_availability() == HostAvailability.UNAVAILABLE: + # The cache entry expired, so the mark has to expire with it. + # set_availability also marks the live HostInfo, and the + # providers that reuse one object forever -- the static + # provider, and the seed host a topology provider reports + # before its dialect is confirmed -- would otherwise stay + # UNAVAILABLE for the process lifetime, excluded from host + # selection with nothing to clear them. + host.set_availability(HostAvailability.AVAILABLE) def _update_hosts(self, new_hosts: Tuple[HostInfo, ...]) -> None: # Diff old vs new topology into per-host HostEvent sets and notify diff --git a/aws_advanced_python_wrapper/aio/read_write_splitting_plugin.py b/aws_advanced_python_wrapper/aio/read_write_splitting_plugin.py index d67c5dbd6..765d70be7 100644 --- a/aws_advanced_python_wrapper/aio/read_write_splitting_plugin.py +++ b/aws_advanced_python_wrapper/aio/read_write_splitting_plugin.py @@ -453,8 +453,18 @@ async def _switch_to_reader_impl( for _ in range(2 * len(reader_candidates)): if not remaining: break - reader = self._plugin_service.get_host_info_by_strategy( - HostRole.READER, strategy, remaining) + try: + reader = self._plugin_service.get_host_info_by_strategy( + HostRole.READER, strategy, remaining) + except Exception as exc: + # The selectors raise HostSelector.NoHostsMatchingRole when + # availability filtering leaves nothing eligible -- reachable + # on async only since availability marking started taking + # effect. Treat it as "no reader available" so callers still + # get ReadWriteSplittingError rather than a bare + # AwsWrapperError escaping from inside selection. + last_error = exc + break if reader is None: break try: diff --git a/aws_advanced_python_wrapper/aio/wrapper.py b/aws_advanced_python_wrapper/aio/wrapper.py index 4ec42b833..d1d967cf7 100644 --- a/aws_advanced_python_wrapper/aio/wrapper.py +++ b/aws_advanced_python_wrapper/aio/wrapper.py @@ -39,6 +39,7 @@ from aws_advanced_python_wrapper.utils.properties import (Properties, PropertiesUtils, WrapperProperties) +from aws_advanced_python_wrapper.utils.rds_utils import RdsUtils from aws_advanced_python_wrapper.utils.telemetry.default_telemetry_factory import \ DefaultTelemetryFactory from aws_advanced_python_wrapper.utils.telemetry.telemetry import \ @@ -49,105 +50,31 @@ if TYPE_CHECKING: from aws_advanced_python_wrapper.aio.driver_dialect.base import \ AsyncDriverDialect - from aws_advanced_python_wrapper.aio.host_list_provider import \ - AsyncHostListProvider from aws_advanced_python_wrapper.aio.plugin import AsyncPlugin from aws_advanced_python_wrapper.database_dialect import DatabaseDialect from aws_advanced_python_wrapper.utils.telemetry.telemetry import \ TelemetryFactory -_TOPOLOGY_REQUIRING_PLUGINS = frozenset({ - "failover", - "failover_v2", - "gdb_failover", - "read_write_splitting", - "gdb_rw", - "custom_endpoint", - "aurora_connection_tracker", -}) - - -def _build_host_list_provider( - props: Properties, - driver_dialect: AsyncDriverDialect, - database_dialect: Any = None, - monitor_connection_factory: Any = None) -> AsyncHostListProvider: - """Pick an async host list provider based on plugins + DB dialect. - - Selection order, matching sync database_dialect.py: - * ``plugins`` references no topology-requiring plugin -> static. - * database_dialect is a MultiAz dialect -> MultiAz provider. - * database_dialect is a GlobalAurora dialect -> GlobalAurora provider. - * Otherwise -> Aurora provider (the default topology path). - - ``database_dialect`` is passed optionally because early-phase callers - (tests, legacy paths) may not have resolved the dialect yet; the - function falls back to a plain Aurora provider in that case. - """ - from aws_advanced_python_wrapper.aio.host_list_provider import ( - AsyncAuroraHostListProvider, AsyncGlobalAuroraHostListProvider, - AsyncMultiAzHostListProvider, AsyncStaticHostListProvider) - from aws_advanced_python_wrapper.aio.plugin_factory import \ - parse_plugins_property - - codes = parse_plugins_property(props) or [] - if not any(c.strip() in _TOPOLOGY_REQUIRING_PLUGINS for c in codes): - return AsyncStaticHostListProvider(props) - - # Topology-aware path. Pick the provider matching the dialect. - if database_dialect is not None: - dialect_name = type(database_dialect).__name__ - if "MultiAz" in dialect_name: - # Pull MultiAz queries off the dialect class. Sync exposes - # them as class-level constants; fall back to no provider - # if they're missing so callers degrade gracefully. - writer_q = getattr( - database_dialect, "_WRITER_HOST_QUERY", None) - topology_q = getattr( - database_dialect, "_TOPOLOGY_QUERY", None) - host_id_q = getattr( - database_dialect, "_HOST_ID_QUERY", - getattr(database_dialect, "host_id_query", None)) - if writer_q and topology_q and host_id_q: - # MySQL's writer_host_query (SHOW REPLICA STATUS) carries the - # writer host at column 39; PG's single-column query uses 0. - # Sync threads this via MultiAzTopologyUtils -- mirror it here. - writer_col = getattr( - database_dialect, "_WRITER_HOST_COLUMN_INDEX", 0) - return AsyncMultiAzHostListProvider( - props, driver_dialect, - writer_host_query=writer_q, - topology_query=topology_q, - host_id_query=host_id_q, - monitor_connection_factory=monitor_connection_factory, - writer_host_column_index=writer_col, - ) - if "GlobalAurora" in dialect_name: - topology_q = getattr( - database_dialect, "topology_query", None) - if topology_q: - return AsyncGlobalAuroraHostListProvider( - props, driver_dialect, topology_query=topology_q, - monitor_connection_factory=monitor_connection_factory) - - return AsyncAuroraHostListProvider( - props, driver_dialect, - monitor_connection_factory=monitor_connection_factory) - - def _resolve_database_dialect( driver_dialect: AsyncDriverDialect, - props: Properties) -> DatabaseDialect: + props: Properties) -> Tuple[DatabaseDialect, bool, DatabaseDialectManager]: """Resolve the initial ``DatabaseDialect`` guess. Honors the ``wrapper_dialect`` prop if set; otherwise falls back to the driver-dialect's default database dialect via :class:`DatabaseDialectManager`. The plugin service later upgrades the dialect from the live connection (``update_database_dialect``), mirroring sync. + + Returns the dialect, whether it is already confirmed, and the manager itself. + The manager is returned rather than discarded because the live probe runs on it: it holds the + ``can_update`` / ``confirmed`` state and the resolved dialect code that + ``query_for_dialect_async`` needs in order to walk the candidate chain + and populate the endpoint cache. """ manager = DatabaseDialectManager(props) - return manager.get_dialect(driver_dialect.dialect_code, props) + dialect = manager.get_dialect(driver_dialect.dialect_code, props) + return dialect, manager.is_confirmed_dialect(), manager class AsyncAwsWrapperCursor: @@ -821,11 +748,11 @@ async def _connect_pipeline( # Parity with the sync wrapper (PluginManager.create_plugins): when # NEITHER an explicit plugin list (``plugins=[...]``) NOR a ``plugins`` # connection property is supplied, apply the default plugin list so - # failover/EFM/etc. load by default. Inject into props (not into - # build_async_plugins) so _build_host_list_provider stays in sync -- it - # reads the same property to choose a topology vs static provider. An - # explicit list leaves the ``plugins`` param non-None (skipped here); an - # explicit ``plugins=""`` leaves the key present (skipped -> no plugins). + # failover/EFM/etc. load by default. Injected into props rather than + # into build_async_plugins so every reader of the property sees the + # same list. An explicit list leaves the ``plugins`` param non-None + # (skipped here); an explicit ``plugins=""`` leaves the key present + # (skipped -> no plugins). # Async MySQL keeps host_monitoring_v2 (aiomysql EFM works via asyncio # cancellation), so both async engines use the full DEFAULT_PLUGINS. if plugins is None and WrapperProperties.PLUGINS.name not in props: @@ -857,37 +784,17 @@ async def _connect_pipeline( port = int(port_raw) if port_raw is not None else -1 host_info = HostInfo(host=host, port=port) - # Resolve the database dialect first so the host-list-provider - # selection can pick a MultiAz/GlobalAurora variant when the - # dialect class indicates it (matches sync's behavior). - database_dialect = _resolve_database_dialect(driver_dialect, props) - - # Dedicated topology-monitor connection factory: opens a FRESH raw - # monitoring connection (no plugins) to the seed host with - # topology-monitoring props. The background topology monitor uses this - # instead of the shared app connection -- driver connections (aiomysql) - # can't service concurrent queries, so a background refresh on the app's - # connection corrupts the app's in-flight query. Mirrors sync's monitor, - # which force_connects its own connection. - async def _monitor_conn_factory() -> Any: - from aws_advanced_python_wrapper.utils.properties import \ - PropertiesUtils - mon_props = PropertiesUtils.create_topology_monitoring_properties( - Properties(dict(props))) - return await driver_dialect.connect(host_info, mon_props, target_func) - - # Host-list-provider selection: if `plugins=...` references any - # topology-requiring plugin (failover, rws), build a topology - # provider matching the dialect; otherwise static is enough. - host_list_provider = _build_host_list_provider( - props, driver_dialect, database_dialect, - monitor_connection_factory=_monitor_conn_factory) + # Resolve the database dialect first: it decides which host list + # provider gets built, and whether that choice is already final. + database_dialect, is_dialect_confirmed, dialect_manager = _resolve_database_dialect( + driver_dialect, props) plugin_service = AsyncPluginServiceImpl( props=props, driver_dialect=driver_dialect, host_info=host_info, ) + # Share the connect-time telemetry factory (sync parity: sync passes # DefaultTelemetryFactory into PluginManager). Must happen BEFORE # AsyncPluginManager is built -- the manager caches the service's @@ -897,15 +804,26 @@ async def _monitor_conn_factory() -> Any: # reach for them in their own ``connect`` hook (e.g., failover # checking ``is_network_exception``) have them available. plugin_service.database_dialect = database_dialect - plugin_service.host_list_provider = host_list_provider + plugin_service.is_dialect_confirmed = is_dialect_confirmed + plugin_service.database_dialect_manager = dialect_manager plugin_service.initial_connection_host_info = host_info + plugin_service.set_target_driver_func(target_func) + host_list_provider_init = database_dialect.get_async_host_list_provider_supplier() + host_list_provider = host_list_provider_init(plugin_service, props) + plugin_service.host_list_provider = host_list_provider + cluster_id = (props.get(WrapperProperties.CLUSTER_ID.name) + or RdsUtils().get_cluster_id(host)) + logger.debug( + "AsyncAwsWrapperConnection.HostListProviderInitialized", + type(host_list_provider).__name__, + cluster_id or "", + type(database_dialect).__name__, + is_dialect_confirmed) # Arm the topology monitor's panic-mode writer discovery: when the # monitoring connection dies mid-failover, the monitor probes every # host through the plugin pipeline to find the new writer (mirrors - # sync ClusterTopologyMonitor's per-host HostMonitor threads). The - # probe needs the plugin service, which didn't exist when the - # provider was built above. + # sync ClusterTopologyMonitor's per-host HostMonitor threads). set_probe = getattr(host_list_provider, "set_probe_host", None) if callable(set_probe): from aws_advanced_python_wrapper.aio.cluster_topology_monitor import \ @@ -930,16 +848,10 @@ async def _monitor_conn_factory() -> Any: plugins=plugins, ) plugin_service.plugin_manager = plugin_manager - plugin_service.set_target_driver_func(target_func) # Run the init_host_provider pipeline (sync parity: wrapper.py:62 -- # after the default provider is selected, before the initial - # connect). Subscribed plugins capture the host-list-provider - # service (implemented by the plugin service) and can validate or - # replace the provider chosen by _build_host_list_provider above. - # The dialect->provider mapping lives in that helper rather than on - # the dialect because the shared sync dialects' provider suppliers - # return SYNC providers. + # connect). plugin_manager.init_host_provider(props, plugin_service) target_conn = await plugin_manager.connect( diff --git a/aws_advanced_python_wrapper/database_dialect.py b/aws_advanced_python_wrapper/database_dialect.py index 4c092c507..18e37fd1f 100644 --- a/aws_advanced_python_wrapper/database_dialect.py +++ b/aws_advanced_python_wrapper/database_dialect.py @@ -14,9 +14,14 @@ from __future__ import annotations -from typing import (TYPE_CHECKING, Callable, ClassVar, Dict, FrozenSet, List, - Optional, Protocol, Sequence, Tuple, runtime_checkable) - +from typing import (TYPE_CHECKING, Any, Callable, ClassVar, Dict, FrozenSet, + List, Optional, Protocol, Sequence, Tuple, + runtime_checkable) + +from aws_advanced_python_wrapper.dialect_verifier import ( + AsyncDialectVerifier, DialectVerificationRules, Rule, SyncDialectVerifier, + all_of, col_contains_ignore_case, col_equals_ignore_case, col_falsy, + col_greater_than, col_non_empty_str, col_truthy) from aws_advanced_python_wrapper.driver_info import DriverInfo from aws_advanced_python_wrapper.host_list_provider import ( AuroraTopologyUtils, ConnectionStringHostListProvider, @@ -165,14 +170,52 @@ def dialect_update_candidates(self) -> Optional[Tuple[DialectCode, ...]]: def exception_handler(self) -> Optional[ExceptionHandler]: ... - @abstractmethod + @property + def verification_rules(self) -> Optional[DialectVerificationRules]: + """The queries and row conditions that identify this dialect. + + Declared as data so one set of rules serves both engines: the sync + and async verifiers in :mod:`dialect_verifier` run the same rules + against a DB-API or an async connection respectively. Writing them + once is the point -- a hand-written sync body and a hand-written + async body drift, which is how the async layer came to recognise + only one of the six candidate dialects. + + None means the dialect cannot be identified by querying, so it is + never selected from a candidate list; the pattern-based guess + stands. That is the conservative answer and what + :class:`UnknownDatabaseDialect` wants. + """ + return None + def is_dialect(self, conn: Connection, driver_dialect: DriverDialect) -> bool: - ... + """Verify whether the database dialect via a live connection. + """ + try: + return SyncDialectVerifier.run(self.verification_rules, conn, driver_dialect) + except Exception: # noqa: BLE001 - failed verification means "not this dialect" + return False + + async def is_dialect_async(self, conn: Any, driver_dialect: Any) -> bool: + """AVerify whether the database dialect via a live connection. + """ + try: + return await AsyncDialectVerifier.run(self.verification_rules, conn, driver_dialect) + except Exception: # noqa: BLE001 - failed verification means "not this dialect" + return False @abstractmethod def get_host_list_provider_supplier(self, plugin_service: PluginService) -> Callable: ... + def get_async_host_list_provider_supplier(self) -> Callable: + """Returns a ``(provider_service, props) -> AsyncHostListProvider`` + callable.Async counterpart of :meth:`get_host_list_provider_supplier`. + """ + from aws_advanced_python_wrapper.aio.host_list_provider import \ + create_static_provider + return create_static_provider + @abstractmethod def prepare_conn_props(self, props: Properties): ... @@ -204,6 +247,11 @@ def query_for_dialect(self, url: str, host_info: Optional[HostInfo], conn: Conne """Returns the dialect identified by querying the database to identify the engine type""" ... + def is_confirmed_dialect(self) -> bool: + """Whether the database dialect has been finalized and can't be changed. + """ + ... + class MysqlDatabaseDialect(DatabaseDialect): _DIALECT_UPDATE_CANDIDATES: Tuple[DialectCode, ...] = ( @@ -212,6 +260,8 @@ class MysqlDatabaseDialect(DatabaseDialect): _HOST_ID_EXPRESSION = "CONCAT(@@hostname, ':', @@port)" _HOST_ID_QUERY = f"SELECT @@hostname AS host, {_HOST_ID_EXPRESSION} AS host_id" _IS_READER_QUERY = "SELECT @@read_only" + _SERVER_VERSION_QUERY = "SHOW VARIABLES LIKE 'version_comment'" + _REPORT_HOST_QUERY = "SHOW VARIABLES LIKE 'report_host'" @property def default_port(self) -> int: @@ -223,7 +273,7 @@ def host_alias_query(self) -> str: @property def server_version_query(self) -> str: - return "SHOW VARIABLES LIKE 'version_comment'" + return MysqlDatabaseDialect._SERVER_VERSION_QUERY @property def host_id_query(self) -> str: @@ -244,21 +294,17 @@ def exception_handler(self) -> Optional[ExceptionHandler]: def dialect_update_candidates(self) -> Optional[Tuple[DialectCode, ...]]: return MysqlDatabaseDialect._DIALECT_UPDATE_CANDIDATES - def is_dialect(self, conn: Connection, driver_dialect: DriverDialect) -> bool: - initial_transaction_status: bool = driver_dialect.is_in_transaction(conn) - try: - with closing(conn.cursor()) as cursor: - cursor.execute(self.server_version_query) - for record in cursor: - if len(record) < 2: - return False - if "mysql" in record[1].lower(): - return True - except Exception: - if not initial_transaction_status and driver_dialect.is_in_transaction(conn): - conn.rollback() + # The version comment names the engine; MariaDB and others answer the + # same query without "mysql" in it. + _VERIFICATION_RULES = DialectVerificationRules(rules=( + Rule(_SERVER_VERSION_QUERY, + col_contains_ignore_case(1, "mysql"), + match_any_row=True), + )) - return False + @property + def verification_rules(self) -> Optional[DialectVerificationRules]: + return MysqlDatabaseDialect._VERIFICATION_RULES def get_host_list_provider_supplier(self, plugin_service: PluginService) -> Callable: return lambda provider_service, props: ConnectionStringHostListProvider(provider_service, props) @@ -306,18 +352,14 @@ def exception_handler(self) -> Optional[ExceptionHandler]: "aws_advanced_python_wrapper.utils.pg_exception_handler.SingleAzPgExceptionHandler") return PgDatabaseDialect._exception_handler - def is_dialect(self, conn: Connection, driver_dialect: DriverDialect) -> bool: - initial_transaction_status: bool = driver_dialect.is_in_transaction(conn) - try: - with closing(conn.cursor()) as cursor: - cursor.execute('SELECT 1 FROM pg_catalog.pg_proc LIMIT 1') - if cursor.fetchone() is not None: - return True - except Exception: - if not initial_transaction_status and driver_dialect.is_in_transaction(conn): - conn.rollback() + _PG_CATALOG_EXISTS_QUERY = 'SELECT 1 FROM pg_catalog.pg_proc LIMIT 1' + _VERIFICATION_RULES = DialectVerificationRules(rules=( + Rule(_PG_CATALOG_EXISTS_QUERY), + )) - return False + @property + def verification_rules(self) -> Optional[DialectVerificationRules]: + return PgDatabaseDialect._VERIFICATION_RULES def get_host_list_provider_supplier(self, plugin_service: PluginService) -> Callable: return lambda provider_service, props: ConnectionStringHostListProvider(provider_service, props) @@ -351,31 +393,18 @@ class RdsMysqlDialect(MysqlDatabaseDialect, BlueGreenDialect): def host_id_query(self) -> str: return self._HOST_ID_QUERY - def is_dialect(self, conn: Connection, driver_dialect: DriverDialect) -> bool: - initial_transaction_status: bool = driver_dialect.is_in_transaction(conn) - try: - with closing(conn.cursor()) as cursor: - cursor.execute(self.server_version_query) - record = cursor.fetchone() - if record is None or len(record) < 2: - return False - - if "source distribution" != record[1].lower(): - return False - - with closing(conn.cursor()) as cursor: - cursor.execute("SHOW VARIABLES LIKE 'report_host'") - record = cursor.fetchone() - if record is None or len(record) < 2: - return False + # "source distribution" in the version comment is how an RDS MySQL host + # identifies itself, and report_host must be set for the topology + # queries to return anything usable. + _VERIFICATION_RULES = DialectVerificationRules(rules=( + Rule(MysqlDatabaseDialect._SERVER_VERSION_QUERY, + col_equals_ignore_case(1, "source distribution")), + Rule(MysqlDatabaseDialect._REPORT_HOST_QUERY, col_non_empty_str(1)), + )) - report_host = record[1] - return report_host is not None and report_host != "" - except Exception: - if not initial_transaction_status and driver_dialect.is_in_transaction(conn): - conn.rollback() - - return False + @property + def verification_rules(self) -> Optional[DialectVerificationRules]: + return RdsMysqlDialect._VERIFICATION_RULES @property def dialect_update_candidates(self) -> Optional[Tuple[DialectCode, ...]]: @@ -412,26 +441,17 @@ class RdsPgDialect(PgDatabaseDialect, BlueGreenDialect): def host_id_query(self) -> str: return self._HOST_ID_QUERY - def is_dialect(self, conn: Connection, driver_dialect: DriverDialect) -> bool: - initial_transaction_status: bool = driver_dialect.is_in_transaction(conn) - if not super().is_dialect(conn, driver_dialect): - return False - - try: - with closing(conn.cursor()) as cursor: - cursor.execute(RdsPgDialect._EXTENSIONS_QUERY) - for row in cursor: - rds_tools = bool(row[0]) - aurora_utils = bool(row[1]) - logger.debug( - "RdsPgDialect.RdsToolsAuroraUtils", str(rds_tools), str(aurora_utils)) - if rds_tools and not aurora_utils: - return True + _VERIFICATION_RULES = DialectVerificationRules( + inherits=PgDatabaseDialect._VERIFICATION_RULES, + rules=( + Rule(_EXTENSIONS_QUERY, + all_of(col_truthy(0), col_falsy(1)), + match_any_row=True), + )) - except Exception: - if not initial_transaction_status and driver_dialect.is_in_transaction(conn): - conn.rollback() - return False + @property + def verification_rules(self) -> Optional[DialectVerificationRules]: + return RdsPgDialect._VERIFICATION_RULES @property def dialect_update_candidates(self) -> Optional[Tuple[DialectCode, ...]]: @@ -466,6 +486,7 @@ class AuroraMysqlDialect(MysqlDatabaseDialect, TopologyAwareDatabaseDialect, Blu _BG_STATUS_QUERY = "SELECT version, endpoint, port, role, status FROM mysql.rds_topology" _BG_STATUS_EXISTS_QUERY = \ "SELECT 1 AS tmp FROM information_schema.tables WHERE table_schema = 'mysql' AND table_name = 'rds_topology'" + _AURORA_VERSION_QUERY = "SHOW VARIABLES LIKE 'aurora_version'" @property def is_reader_query(self) -> str: @@ -475,19 +496,14 @@ def is_reader_query(self) -> str: def dialect_update_candidates(self) -> Optional[Tuple[DialectCode, ...]]: return AuroraMysqlDialect._DIALECT_UPDATE_CANDIDATES - def is_dialect(self, conn: Connection, driver_dialect: DriverDialect) -> bool: - initial_transaction_status: bool = driver_dialect.is_in_transaction(conn) - try: - with closing(conn.cursor()) as cursor: - cursor.execute("SHOW VARIABLES LIKE 'aurora_version'") - # If variable with such a name is presented then it means it's an Aurora cluster - if cursor.fetchone() is not None: - return True - except Exception: - if not initial_transaction_status and driver_dialect.is_in_transaction(conn): - conn.rollback() + # The variable existing at all means an Aurora cluster. + _VERIFICATION_RULES = DialectVerificationRules(rules=( + Rule(_AURORA_VERSION_QUERY), + )) - return False + @property + def verification_rules(self) -> Optional[DialectVerificationRules]: + return AuroraMysqlDialect._VERIFICATION_RULES def get_host_list_provider_supplier(self, plugin_service: PluginService) -> Callable: return lambda provider_service, props: RdsHostListProvider( @@ -496,6 +512,14 @@ def get_host_list_provider_supplier(self, plugin_service: PluginService) -> Call props, AuroraTopologyUtils(self, props)) + def get_async_host_list_provider_supplier(self) -> Callable: + from aws_advanced_python_wrapper.aio.host_list_provider import \ + create_aurora_provider + return lambda provider_service, props: create_aurora_provider( + provider_service, props, + topology_query=self.topology_query, + default_port=self.default_port) + @property def blue_green_status_query(self) -> str: return AuroraMysqlDialect._BG_STATUS_QUERY @@ -540,38 +564,18 @@ class AuroraPgDialect(PgDatabaseDialect, TopologyAwareDatabaseDialect, AuroraLim def dialect_update_candidates(self) -> Optional[Tuple[DialectCode, ...]]: return AuroraPgDialect._DIALECT_UPDATE_CANDIDATES - def is_dialect(self, conn: Connection, driver_dialect: DriverDialect) -> bool: - if not super().is_dialect(conn, driver_dialect): - return False - - has_extensions: bool = False - has_topology: bool = False - - initial_transaction_status: bool = driver_dialect.is_in_transaction(conn) - try: - with closing(conn.cursor()) as cursor: - cursor.execute(self._AURORA_UTILS_EXIST_QUERY) - row = cursor.fetchone() - if row is None: - return False - - aurora_utils = bool(row[0]) - logger.debug("AuroraPgDialect.AuroraUtils", aurora_utils) - if aurora_utils: - has_extensions = True - - with closing(conn.cursor()) as cursor: - cursor.execute(self._HAS_TOPOLOGY_QUERY) - if cursor.fetchone() is not None: - logger.debug("AuroraPgDialect.HasTopologyTrue") - has_topology = True - - return has_extensions and has_topology - except Exception: - if not initial_transaction_status and driver_dialect.is_in_transaction(conn): - conn.rollback() + # Aurora PG needs both the aurora_stat_utils extension and the + # topology function; plain RDS PG has neither. + _VERIFICATION_RULES = DialectVerificationRules( + inherits=PgDatabaseDialect._VERIFICATION_RULES, + rules=( + Rule(_AURORA_UTILS_EXIST_QUERY, col_truthy(0)), + Rule(_HAS_TOPOLOGY_QUERY), + )) - return False + @property + def verification_rules(self) -> Optional[DialectVerificationRules]: + return AuroraPgDialect._VERIFICATION_RULES def get_host_list_provider_supplier(self, plugin_service: PluginService) -> Callable: return lambda provider_service, props: RdsHostListProvider( @@ -580,6 +584,14 @@ def get_host_list_provider_supplier(self, plugin_service: PluginService) -> Call props, AuroraTopologyUtils(self, props)) + def get_async_host_list_provider_supplier(self) -> Callable: + from aws_advanced_python_wrapper.aio.host_list_provider import \ + create_aurora_provider + return lambda provider_service, props: create_aurora_provider( + provider_service, props, + topology_query=self.topology_query, + default_port=self.default_port) + @property def blue_green_status_query(self) -> str: return AuroraPgDialect._BG_STATUS_QUERY @@ -612,27 +624,18 @@ class GlobalAuroraMysqlDialect(AuroraMysqlDialect, GlobalAuroraTopologyDialect): def dialect_update_candidates(self) -> Optional[Tuple[DialectCode, ...]]: return None - def is_dialect(self, conn: Connection, driver_dialect: DriverDialect) -> bool: - initial_transaction_status: bool = driver_dialect.is_in_transaction(conn) - try: - if not DialectUtils.check_existence_queries( - conn, (self._GLOBAL_STATUS_TABLE_EXISTS_QUERY, - self._GLOBAL_INSTANCE_STATUS_EXISTS_QUERY)): - return False - - with closing(conn.cursor()) as cursor: - cursor.execute(self._REGION_COUNT_QUERY) - record = cursor.fetchone() - if record is None or len(record) < 1: - return False - - aws_region_count = record[0] - return aws_region_count is not None and aws_region_count > 1 - except Exception: - if not initial_transaction_status and driver_dialect.is_in_transaction(conn): - conn.rollback() + # Both global tables must exist AND report more than one region -- + # a single-region Aurora cluster has the tables but is not a global + # database. + _VERIFICATION_RULES = DialectVerificationRules(rules=( + Rule(_GLOBAL_STATUS_TABLE_EXISTS_QUERY), + Rule(_GLOBAL_INSTANCE_STATUS_EXISTS_QUERY), + Rule(_REGION_COUNT_QUERY, col_greater_than(0, 1)), + )) - return False + @property + def verification_rules(self) -> Optional[DialectVerificationRules]: + return GlobalAuroraMysqlDialect._VERIFICATION_RULES def get_host_list_provider_supplier(self, plugin_service: PluginService) -> Callable: return lambda provider_service, props: GlobalAuroraHostListProvider( @@ -641,6 +644,14 @@ def get_host_list_provider_supplier(self, plugin_service: PluginService) -> Call props, GlobalAuroraTopologyUtils(self, props)) + def get_async_host_list_provider_supplier(self) -> Callable: + from aws_advanced_python_wrapper.aio.host_list_provider import \ + create_global_aurora_provider + return lambda provider_service, props: create_global_aurora_provider( + provider_service, props, + topology_query=self.topology_query, + default_port=self.default_port) + def filter_available_hosts( self, hosts: Sequence[HostInfo], @@ -664,39 +675,18 @@ class GlobalAuroraPgDialect(AuroraPgDialect, GlobalAuroraTopologyDialect): def dialect_update_candidates(self) -> Optional[Tuple[DialectCode, ...]]: return None - def is_dialect(self, conn: Connection, driver_dialect: DriverDialect) -> bool: - initial_transaction_status: bool = driver_dialect.is_in_transaction(conn) - try: - with closing(conn.cursor()) as cursor: - cursor.execute(self._AURORA_UTILS_EXIST_QUERY) - row = cursor.fetchone() - if row is None: - return False - - aurora_utils = bool(row[0]) - logger.debug("AuroraPgDialect.AuroraUtils", aurora_utils) - if not aurora_utils: - return False + # Aurora PG's extension first, then both global views, then more than + # one region -- a single-region cluster is not a global database. + _VERIFICATION_RULES = DialectVerificationRules(rules=( + Rule(AuroraPgDialect._AURORA_UTILS_EXIST_QUERY, col_truthy(0)), + Rule(_GLOBAL_STATUS_TABLE_EXISTS_QUERY), + Rule(_GLOBAL_INSTANCE_STATUS_EXISTS_QUERY), + Rule(_REGION_COUNT_QUERY, col_greater_than(0, 1)), + )) - if not DialectUtils.check_existence_queries( - conn, (self._GLOBAL_STATUS_TABLE_EXISTS_QUERY, - self._GLOBAL_INSTANCE_STATUS_EXISTS_QUERY)): - return False - - with closing(conn.cursor()) as cursor: - cursor.execute(self._REGION_COUNT_QUERY) - record = cursor.fetchone() - if record is None or len(record) < 1: - return False - - aws_region_count = record[0] - return aws_region_count is not None and aws_region_count > 1 - - except Exception: - if not initial_transaction_status and driver_dialect.is_in_transaction(conn): - conn.rollback() - - return False + @property + def verification_rules(self) -> Optional[DialectVerificationRules]: + return GlobalAuroraPgDialect._VERIFICATION_RULES def get_host_list_provider_supplier(self, plugin_service: PluginService) -> Callable: return lambda provider_service, props: GlobalAuroraHostListProvider( @@ -705,6 +695,14 @@ def get_host_list_provider_supplier(self, plugin_service: PluginService) -> Call props, GlobalAuroraTopologyUtils(self, props)) + def get_async_host_list_provider_supplier(self) -> Callable: + from aws_advanced_python_wrapper.aio.host_list_provider import \ + create_global_aurora_provider + return lambda provider_service, props: create_global_aurora_provider( + provider_service, props, + topology_query=self.topology_query, + default_port=self.default_port) + def filter_available_hosts( self, hosts: Sequence[HostInfo], @@ -725,28 +723,16 @@ class MultiAzClusterMysqlDialect(MysqlDatabaseDialect, TopologyAwareDatabaseDial def dialect_update_candidates(self) -> Optional[Tuple[DialectCode, ...]]: return None - def is_dialect(self, conn: Connection, driver_dialect: DriverDialect) -> bool: - initial_transaction_status: bool = driver_dialect.is_in_transaction(conn) - try: - with closing(conn.cursor()) as cursor: - cursor.execute(MultiAzClusterMysqlDialect._TOPOLOGY_QUERY) - records = cursor.fetchall() - if not records: - return False - - with closing(conn.cursor()) as cursor: - cursor.execute("SHOW VARIABLES LIKE 'report_host'") - record = cursor.fetchone() - if record is None or len(record) < 2: - return False - - report_host = record[1] - return report_host is not None and report_host != "" - except Exception: - if not initial_transaction_status and driver_dialect.is_in_transaction(conn): - conn.rollback() + # The Multi-AZ topology view returning rows, plus report_host set so + # those rows can be matched to this host. + _VERIFICATION_RULES = DialectVerificationRules(rules=( + Rule(_TOPOLOGY_QUERY, match_any_row=True), + Rule(MysqlDatabaseDialect._REPORT_HOST_QUERY, col_non_empty_str(1)), + )) - return False + @property + def verification_rules(self) -> Optional[DialectVerificationRules]: + return MultiAzClusterMysqlDialect._VERIFICATION_RULES def get_host_list_provider_supplier(self, plugin_service: PluginService) -> Callable: return lambda provider_service, props: RdsHostListProvider( @@ -755,6 +741,17 @@ def get_host_list_provider_supplier(self, plugin_service: PluginService) -> Call props, MultiAzTopologyUtils(self, props, self._WRITER_HOST_QUERY, self._WRITER_HOST_COLUMN_INDEX)) + def get_async_host_list_provider_supplier(self) -> Callable: + from aws_advanced_python_wrapper.aio.host_list_provider import \ + create_multi_az_provider + return lambda provider_service, props: create_multi_az_provider( + provider_service, props, + writer_host_query=self.writer_id_query, + topology_query=self.topology_query, + host_id_query=self.host_id_query, + default_port=self.default_port, + writer_host_column_index=self._WRITER_HOST_COLUMN_INDEX) + def prepare_conn_props(self, props: Properties): # These props are added for RDS metrics purposes, they are not required for functional correctness. # The "conn_attrs" property value is specified as a dict. @@ -791,19 +788,15 @@ def exception_handler(self) -> Optional[ExceptionHandler]: "aws_advanced_python_wrapper.utils.pg_exception_handler.MultiAzPgExceptionHandler") return MultiAzClusterPgDialect._exception_handler - def is_dialect(self, conn: Connection, driver_dialect: DriverDialect) -> bool: - initial_transaction_status: bool = driver_dialect.is_in_transaction(conn) - try: - with closing(conn.cursor()) as cursor: - cursor.execute(MultiAzClusterPgDialect._WRITER_HOST_QUERY) - record = cursor.fetchone() - if record is not None and len(record) > 0 and bool(record[0]): - return True - except Exception: - if not initial_transaction_status and driver_dialect.is_in_transaction(conn): - conn.rollback() + # The writer query answering with an id: only a Multi-AZ PG cluster + # exposes rds_tools.multi_az_db_cluster_source_dbi_resource_id(). + _VERIFICATION_RULES = DialectVerificationRules(rules=( + Rule(_WRITER_HOST_QUERY, col_truthy(0)), + )) - return False + @property + def verification_rules(self) -> Optional[DialectVerificationRules]: + return MultiAzClusterPgDialect._VERIFICATION_RULES def get_host_list_provider_supplier(self, plugin_service: PluginService) -> Callable: return lambda provider_service, props: RdsHostListProvider( @@ -812,6 +805,19 @@ def get_host_list_provider_supplier(self, plugin_service: PluginService) -> Call props, MultiAzTopologyUtils(self, props, self._WRITER_HOST_QUERY)) + def get_async_host_list_provider_supplier(self) -> Callable: + from aws_advanced_python_wrapper.aio.host_list_provider import \ + create_multi_az_provider + + # No writer_host_column_index: the PG writer query returns a single + # column. + return lambda provider_service, props: create_multi_az_provider( + provider_service, props, + writer_host_query=self.writer_id_query, + topology_query=self.topology_query, + host_id_query=self.host_id_query, + default_port=self.default_port) + class UnknownDatabaseDialect(DatabaseDialect): _DIALECT_UPDATE_CANDIDATES: Optional[Tuple[DialectCode, ...]] = \ @@ -854,9 +860,6 @@ def dialect_update_candidates(self) -> Optional[Tuple[DialectCode, ...]]: def exception_handler(self) -> Optional[ExceptionHandler]: return None - def is_dialect(self, conn: Connection, driver_dialect: DriverDialect) -> bool: - return False - def get_host_list_provider_supplier(self, plugin_service: PluginService) -> Callable: return lambda provider_service, props: ConnectionStringHostListProvider(provider_service, props) @@ -887,10 +890,18 @@ def __init__(self, props: Properties, rds_helper: Optional[RdsUtils] = None): self._props: Properties = props self._rds_helper: RdsUtils = rds_helper if rds_helper else RdsUtils() self._can_update: bool = False + # Complement of _can_update: set wherever the resolved dialect is + # final. Tracked separately because _can_update also goes False on + # the paths that give up (unknown dialect), where the answer is NOT + # confirmed. + self._confirmed: bool = False self._dialect: DatabaseDialect = UnknownDatabaseDialect() self._dialect_code: DialectCode = DialectCode.UNKNOWN self._thread_pool = services_container.get_thread_pool(self._executor_name) + def is_confirmed_dialect(self) -> bool: + return self._confirmed + @staticmethod def get_custom_dialect(): return DatabaseDialectManager._custom_dialect @@ -908,10 +919,14 @@ def reset_endpoint_cache(self): def get_dialect(self, driver_dialect: str, props: Properties) -> DatabaseDialect: self._can_update = False + self._confirmed = False if self._custom_dialect is not None: self._dialect_code = DialectCode.CUSTOM self._dialect = self._custom_dialect + # A custom dialect is the user's explicit choice; no verification can + # override it. + self._confirmed = True self._log_current_dialect() return self._dialect @@ -928,6 +943,9 @@ def get_dialect(self, driver_dialect: str, props: Properties) -> DatabaseDialect if dialect: self._dialect_code = dialect_code self._dialect = dialect + # Either the user named the dialect or the endpoint cache + # already holds a verified answer for this url. + self._confirmed = True self._log_current_dialect() return dialect else: @@ -940,6 +958,7 @@ def get_dialect(self, driver_dialect: str, props: Properties) -> DatabaseDialect rds_type = self._rds_helper.identify_rds_type(host) if rds_type == RdsUrlType.RDS_GLOBAL_WRITER_CLUSTER: self._can_update = False + self._confirmed = True self._dialect_code = DialectCode.GLOBAL_AURORA_MYSQL self._dialect = DatabaseDialectManager._known_dialects_by_code[DialectCode.GLOBAL_AURORA_MYSQL] return self._dialect @@ -964,11 +983,13 @@ def get_dialect(self, driver_dialect: str, props: Properties) -> DatabaseDialect rds_type = self._rds_helper.identify_rds_type(host) if rds_type == RdsUrlType.RDS_AURORA_LIMITLESS_DB_SHARD_GROUP: self._can_update = False + self._confirmed = True self._dialect_code = DialectCode.AURORA_PG self._dialect = DatabaseDialectManager._known_dialects_by_code[DialectCode.AURORA_PG] return self._dialect if rds_type == RdsUrlType.RDS_GLOBAL_WRITER_CLUSTER: self._can_update = False + self._confirmed = True self._dialect_code = DialectCode.GLOBAL_AURORA_PG self._dialect = DatabaseDialectManager._known_dialects_by_code[DialectCode.GLOBAL_AURORA_PG] return self._dialect @@ -1006,54 +1027,123 @@ def _get_target_driver_type(self, driver_dialect: str) -> TargetDriverType: def query_for_dialect(self, url: str, host_info: Optional[HostInfo], conn: Connection, driver_dialect: DriverDialect) -> DatabaseDialect: if not self._can_update: + self._confirmed = True self._log_current_dialect() return self._dialect - dialect_candidates = self._dialect.dialect_update_candidates if self._dialect is not None else None - if dialect_candidates is not None: - for dialect_code in dialect_candidates: - dialect_candidate = DatabaseDialectManager._known_dialects_by_code.get(dialect_code) - if dialect_candidate is None: - raise AwsWrapperError(Messages.get_formatted("DatabaseDialectManager.UnknownDialectCode", dialect_code)) - - timeout_sec = WrapperProperties.AUXILIARY_QUERY_TIMEOUT_SEC.get(self._props) - try: - cursor_execute_func_with_timeout = preserve_transaction_status_with_timeout( - self._thread_pool, - timeout_sec, - driver_dialect, - conn)(dialect_candidate.is_dialect) - is_dialect = cursor_execute_func_with_timeout(conn, driver_dialect) - except TimeoutError as e: - raise QueryTimeoutError("DatabaseDialectManager.QueryForDialectTimeout") from e - - if not is_dialect: - continue + for dialect_code, dialect_candidate in self._candidates(): + timeout_sec = WrapperProperties.AUXILIARY_QUERY_TIMEOUT_SEC.get(self._props) + try: + cursor_execute_func_with_timeout = preserve_transaction_status_with_timeout( + self._thread_pool, + timeout_sec, + driver_dialect, + conn)(dialect_candidate.is_dialect) + is_dialect = cursor_execute_func_with_timeout(conn, driver_dialect) + except TimeoutError as e: + raise QueryTimeoutError("DatabaseDialectManager.QueryForDialectTimeout") from e + + if is_dialect: + return self._confirm_candidate(dialect_code, dialect_candidate, url, host_info) + + return self._confirm_current(url, host_info) + + async def query_for_dialect_async( + self, url: str, host_info: Optional[HostInfo], conn: Any, + driver_dialect: Any) -> DatabaseDialect: + """Async counterpart of :meth:`query_for_dialect`. + + Walks the same ``dialect_update_candidates`` chain and shares the + same bookkeeping -- the endpoint cache, ``can_update`` and + ``confirmed`` -- so an async connect contributes to and benefits + from the cache exactly as a sync one does. + + The timeout comes from ``asyncio.wait_for`` rather than the thread + pool the sync path uses, since verification is awaited rather than + offloaded. The transaction-status restore that + ``preserve_transaction_status_with_timeout`` provides on the sync + side is done by :class:`AsyncDialectVerifier` instead. + + Runs the verifier directly rather than through + ``is_dialect_async`` so that a candidate that could not be verified + is distinguishable from one that answered no. When no candidate + matched *and* a verification failed, this raises instead of settling: + settling would write an unverified guess into the endpoint cache, + where every later connection to that host would reuse it. + """ + import asyncio - self._can_update = False - self._dialect_code = dialect_code - self._dialect = dialect_candidate - DatabaseDialectManager._known_endpoint_dialects.put(url, dialect_code, - DatabaseDialectManager._ENDPOINT_CACHE_EXPIRATION_NS) - if host_info is not None: - DatabaseDialectManager._known_endpoint_dialects.put( - host_info.url, dialect_code, DatabaseDialectManager._ENDPOINT_CACHE_EXPIRATION_NS) + if not self._can_update: + self._confirmed = True + self._log_current_dialect() + return self._dialect - self._log_current_dialect() - return self._dialect + timeout_sec = WrapperProperties.AUXILIARY_QUERY_TIMEOUT_SEC.get_float(self._props) + for dialect_code, dialect_candidate in self._candidates(): + try: + is_dialect = await asyncio.wait_for( + AsyncDialectVerifier.run( + dialect_candidate.verification_rules, conn, driver_dialect), + timeout=timeout_sec) + except (asyncio.TimeoutError, TimeoutError) as e: + raise QueryTimeoutError( + Messages.get("DatabaseDialectManager.QueryForDialectTimeout")) from e + except Exception: # noqa: BLE001 - try the next candidate + # Verification asks about objects a given engine may not have, so + # one candidate erroring says nothing about the next. + continue + + if is_dialect: + return self._confirm_candidate(dialect_code, dialect_candidate, url, host_info) + + return self._confirm_current(url, host_info) + + def _candidates(self) -> List[Tuple[DialectCode, DatabaseDialect]]: + """The dialects the current one says it may upgrade to, resolved.""" + dialect_candidates = self._dialect.dialect_update_candidates if self._dialect is not None else None + if dialect_candidates is None: + return [] + + resolved = [] + for dialect_code in dialect_candidates: + dialect_candidate = DatabaseDialectManager._known_dialects_by_code.get(dialect_code) + if dialect_candidate is None: + raise AwsWrapperError(Messages.get_formatted("DatabaseDialectManager.UnknownDialectCode", dialect_code)) + resolved.append((dialect_code, dialect_candidate)) + return resolved + + def _confirm_candidate( + self, dialect_code: DialectCode, dialect_candidate: DatabaseDialect, + url: str, host_info: Optional[HostInfo]) -> DatabaseDialect: + """Settle on a verified candidate and remember it for this endpoint.""" + self._can_update = False + self._confirmed = True + self._dialect_code = dialect_code + self._dialect = dialect_candidate + self._cache_endpoint_dialect(url, host_info, dialect_code) + self._log_current_dialect() + return self._dialect + def _confirm_current(self, url: str, host_info: Optional[HostInfo]) -> DatabaseDialect: + """No candidate matched, so the current dialect is the answer.""" if self._dialect_code is None or self._dialect_code == DialectCode.UNKNOWN: raise AwsWrapperError(Messages.get("DatabaseDialectManager.UnknownDialect")) self._can_update = False - DatabaseDialectManager._known_endpoint_dialects.put(url, self._dialect_code, - DatabaseDialectManager._ENDPOINT_CACHE_EXPIRATION_NS) - if host_info is not None: - DatabaseDialectManager._known_endpoint_dialects.put( - host_info.url, self._dialect_code, DatabaseDialectManager._ENDPOINT_CACHE_EXPIRATION_NS) + self._confirmed = True + self._cache_endpoint_dialect(url, host_info, self._dialect_code) self._log_current_dialect() return self._dialect + @staticmethod + def _cache_endpoint_dialect( + url: str, host_info: Optional[HostInfo], dialect_code: DialectCode) -> None: + DatabaseDialectManager._known_endpoint_dialects.put( + url, dialect_code, DatabaseDialectManager._ENDPOINT_CACHE_EXPIRATION_NS) + if host_info is not None: + DatabaseDialectManager._known_endpoint_dialects.put( + host_info.url, dialect_code, DatabaseDialectManager._ENDPOINT_CACHE_EXPIRATION_NS) + def _log_current_dialect(self): dialect_class = "" if self._dialect is None else type(self._dialect).__name__ logger.debug("DatabaseDialectManager.CurrentDialectCanUpdate", self._dialect_code, dialect_class, self._can_update) diff --git a/aws_advanced_python_wrapper/dialect_verifier.py b/aws_advanced_python_wrapper/dialect_verifier.py new file mode 100644 index 000000000..c19cc9d32 --- /dev/null +++ b/aws_advanced_python_wrapper/dialect_verifier.py @@ -0,0 +1,269 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Database-dialect identification rules, as data plus two verifiers. + +A dialect says *what* identifies it -- a few queries and a predicate over +the rows each returns -- as :class:`DialectVerificationRules`. A verifier +says *how* to run those against a connection. There are two verifiers +because there are two connection kinds, and only the execution differs +between them: :class:`SyncDialectVerifier` for DB-API connections and +:class:`AsyncDialectVerifier` for the async drivers. + +Why composition rather than a sync method and an async method on every +dialect: the rules are the same either way (the queries are already +shared), so writing them twice means the two can silently drift -- which +is how the async layer ended up identifying only one of the six candidate +dialects. Here the rules are written once and both engines can run them, +so a dialect added later works under both without further async work. + +Dialects may still override ``is_dialect`` / ``is_dialect_async`` directly +when their identification does not fit this vocabulary; the rules are the +default, not a requirement. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Callable, Optional, Tuple + +if TYPE_CHECKING: + from aws_advanced_python_wrapper.driver_dialect import DriverDialect + from aws_advanced_python_wrapper.pep249 import Connection + +RowPredicate = Callable[[Tuple], bool] +"""Takes one result row, answers whether it satisfies the rule.""" + + +# -- Row predicates ---------------------------------------------------------- +# +# Each guards the row length itself: a server returning fewer columns than +# expected means "not this dialect", never an IndexError. + +def col_truthy(index: int) -> RowPredicate: + """Column ``index`` is truthy.""" + def predicate(row: Tuple) -> bool: + return len(row) > index and bool(row[index]) + return predicate + + +def col_falsy(index: int) -> RowPredicate: + """Column ``index`` is present but falsy.""" + def predicate(row: Tuple) -> bool: + return len(row) > index and not bool(row[index]) + return predicate + + +def col_non_empty_str(index: int) -> RowPredicate: + """Column ``index`` is a non-empty string. + + An empty value is how MySQL reports an unset ``SHOW VARIABLES`` entry, + so blank has to be distinguished from present. + """ + def predicate(row: Tuple) -> bool: + if len(row) <= index: + return False + value = row[index] + return value is not None and str(value) != "" + return predicate + + +def col_equals_ignore_case(index: int, expected: str) -> RowPredicate: + """Column ``index`` equals ``expected``, case-insensitively.""" + lowered = expected.lower() + + def predicate(row: Tuple) -> bool: + if len(row) <= index or row[index] is None: + return False + return str(row[index]).lower() == lowered + return predicate + + +def col_contains_ignore_case(index: int, needle: str) -> RowPredicate: + """Column ``index`` contains ``needle``, case-insensitively.""" + lowered = needle.lower() + + def predicate(row: Tuple) -> bool: + if len(row) <= index or row[index] is None: + return False + return lowered in str(row[index]).lower() + return predicate + + +def col_greater_than(index: int, threshold: float) -> RowPredicate: + """Column ``index`` is a number greater than ``threshold``.""" + def predicate(row: Tuple) -> bool: + if len(row) <= index: + return False + value = row[index] + return value is not None and value > threshold + return predicate + + +def all_of(*predicates: RowPredicate) -> RowPredicate: + """Every predicate holds for the same row.""" + def predicate(row: Tuple) -> bool: + return all(p(row) for p in predicates) + return predicate + + +# -- Spec -------------------------------------------------------------------- + +@dataclass(frozen=True) +class Rule: + """One query plus the condition its rows must satisfy. + + ``predicate`` of None means the query returning any row at all is the + condition -- the existence tests several dialects rely on. + + ``match_any_row`` scans the whole result set and passes if any row + satisfies the predicate; the default inspects only the first row. The + distinction is load bearing: the MySQL and RDS PG rules read a + multi-row result where the identifying row is not necessarily first. + """ + + query: str + predicate: Optional[RowPredicate] = None + match_any_row: bool = False + + +@dataclass(frozen=True) +class DialectVerificationRules: + """The complete identification test for one dialect. + + All rules must pass. ``inherits`` chains a parent dialect's rules that + must pass first, mirroring the ``super().is_dialect(...)`` calls the + hand-written probes used -- an Aurora PG host is a PG host first. + """ + + rules: Tuple[Rule, ...] = field(default_factory=tuple) + inherits: Optional[DialectVerificationRules] = None + + +# -- Runners ----------------------------------------------------------------- + +class SyncDialectVerifier: + """Runs a :class:`DialectVerificationRules` against a DB-API connection. + + Restores the connection's transaction state on the way out and then + lets the error propagate. Callers decide what a failure means: + ``DatabaseDialect.is_dialect`` treats it as "not this dialect", while + the manager's async candidate walk distinguishes "answered no" from + "could not ask" so it does not cache a guess it never verified. + + The timeout is the caller's too -- + ``DatabaseDialectManager.query_for_dialect`` wraps this in + ``preserve_transaction_status_with_timeout``. + """ + + @staticmethod + def run( + rules: Optional[DialectVerificationRules], + conn: Connection, + driver_dialect: DriverDialect) -> bool: + if rules is None: + return False + + initial_transaction_status: bool = driver_dialect.is_in_transaction(conn) + try: + return SyncDialectVerifier._evaluate(rules, conn) + except Exception: + # Roll back only a transaction verification itself opened: a SELECT + # on an autocommit=False connection leaves it mid-transaction, + # which would then block the caller's session-state changes. + if not initial_transaction_status and driver_dialect.is_in_transaction(conn): + conn.rollback() + raise + + @staticmethod + def _evaluate(rules: DialectVerificationRules, conn: Connection) -> bool: + if rules.inherits is not None and not SyncDialectVerifier._evaluate(rules.inherits, conn): + return False + + for rule in rules.rules: + if not SyncDialectVerifier._evaluate_rule(rule, conn): + return False + return True + + @staticmethod + def _evaluate_rule(rule: Rule, conn: Connection) -> bool: + from contextlib import closing + with closing(conn.cursor()) as cursor: + cursor.execute(rule.query) + if rule.match_any_row: + rows = cursor.fetchall() + if rule.predicate is None: + return bool(rows) + return any(rule.predicate(row) for row in rows) + + row = cursor.fetchone() + if row is None: + return False + return True if rule.predicate is None else rule.predicate(row) + + +class AsyncDialectVerifier: + """Runs a :class:`DialectVerificationRules` against an async connection. + + Same rules, same semantics as :class:`SyncDialectVerifier` -- + including letting errors propagate; only the cursor handling is + awaited. The caller supplies the timeout via ``asyncio.wait_for``. + """ + + @staticmethod + async def run( + rules: Optional[DialectVerificationRules], + conn: Any, + driver_dialect: Any) -> bool: + if rules is None: + return False + + from aws_advanced_python_wrapper.aio.dialect_utils import \ + AsyncDialectUtils + + was_in_transaction = await AsyncDialectUtils._safe_in_transaction( + conn, driver_dialect) + try: + return await AsyncDialectVerifier._evaluate(rules, conn) + finally: + # Unconditional, unlike the sync runner's rollback-on-error: a + # successful SELECT also leaves psycopg INTRANS, which + # then makes the caller's set_read_only/set_autocommit raise. + await AsyncDialectUtils._restore_idle(conn, was_in_transaction) + + @staticmethod + async def _evaluate(rules: DialectVerificationRules, conn: Any) -> bool: + if rules.inherits is not None and not await AsyncDialectVerifier._evaluate(rules.inherits, conn): + return False + + for rule in rules.rules: + if not await AsyncDialectVerifier._evaluate_rule(rule, conn): + return False + return True + + @staticmethod + async def _evaluate_rule(rule: Rule, conn: Any) -> bool: + from aws_advanced_python_wrapper.aio.dialect_utils import \ + AsyncDialectUtils + + if rule.match_any_row: + rows = await AsyncDialectUtils.fetch_all(conn, rule.query) + if rule.predicate is None: + return bool(rows) + return any(rule.predicate(row) for row in rows) + + row = await AsyncDialectUtils.fetch_one(conn, rule.query) + if row is None: + return False + return True if rule.predicate is None else rule.predicate(row) diff --git a/aws_advanced_python_wrapper/failover_v2_plugin.py b/aws_advanced_python_wrapper/failover_v2_plugin.py index 260706367..b275420a1 100644 --- a/aws_advanced_python_wrapper/failover_v2_plugin.py +++ b/aws_advanced_python_wrapper/failover_v2_plugin.py @@ -190,7 +190,7 @@ def _invalid_invocation_on_closed_connection(self) -> None: if not self._closed_explicitly: self._is_closed = False self._pick_new_connection() - logger.info(Messages.get("Failover.connectionChangedError")) + logger.info(Messages.get("FailoverPlugin.ConnectionChangedError")) raise FailoverSuccessError() else: raise AwsWrapperError("No operations allowed after connection closed") diff --git a/aws_advanced_python_wrapper/host_list_provider.py b/aws_advanced_python_wrapper/host_list_provider.py index 8d6164ca5..0d2d8e4cb 100644 --- a/aws_advanced_python_wrapper/host_list_provider.py +++ b/aws_advanced_python_wrapper/host_list_provider.py @@ -222,6 +222,12 @@ def _get_topology(self, conn: Optional[Connection], force_update: bool = False) # Return the original hosts passed to the connect method return RdsHostListProvider.FetchTopologyResult(self._initial_hosts, False) + if not self._plugin_service.is_dialect_confirmed: + # The dialect must be confirmed before the a topology monitor is + # created. Until then, report the hosts parsed from the connection + # properties. + return RdsHostListProvider.FetchTopologyResult(self._initial_hosts, False) + hosts = self._force_refresh_monitor(False, self._DEFAULT_TOPOLOGY_QUERY_TIMEOUT_SEC) if hosts is not None and len(hosts) > 0: return RdsHostListProvider.FetchTopologyResult(hosts, False) @@ -278,6 +284,11 @@ def get_current_topology(self, connection: Connection, initial_host_info: HostIn def force_monitoring_refresh(self, should_verify_writer: bool, timeout_sec: float) -> Topology: """Public API for forcing monitor refresh""" self._initialize() + if not self._plugin_service.is_dialect_confirmed: + # The dialect must be confirmed before the a topology monitor is + # created. Until then, report the hosts parsed from the connection + # properties. + return self._initial_hosts hosts = self._force_refresh_monitor(should_verify_writer, timeout_sec) return hosts if hosts else () diff --git a/aws_advanced_python_wrapper/limitless_plugin.py b/aws_advanced_python_wrapper/limitless_plugin.py index 0e47c1abc..334f29aff 100644 --- a/aws_advanced_python_wrapper/limitless_plugin.py +++ b/aws_advanced_python_wrapper/limitless_plugin.py @@ -194,13 +194,13 @@ def run(self): sleep(self._interval_ms / 1000) except InterruptedError as e: - logger.debug("LimitlessRouterMonitor.InterruptedExceptionDuringMonitoring", self.host_info.host) + logger.debug("LimitlessRouterMonitor.InterruptedErrorDuringMonitoring", self.host_info.host) if telemetry_context: telemetry_context.set_exception(e) telemetry_context.set_success(False) except Exception as e: # this should not be reached; log and exit thread - logger.debug("LimitlessRouterMonitor.ExceptionDuringMonitoringStop", self.host_info.host, e) + logger.debug("LimitlessRouterMonitor.errorDuringMonitoringStop", self.host_info.host, e) if telemetry_context: telemetry_context.set_exception(e) telemetry_context.set_success(False) diff --git a/aws_advanced_python_wrapper/plugin_service.py b/aws_advanced_python_wrapper/plugin_service.py index f6296d876..74ac133b4 100644 --- a/aws_advanced_python_wrapper/plugin_service.py +++ b/aws_advanced_python_wrapper/plugin_service.py @@ -205,6 +205,13 @@ def driver_dialect(self) -> DriverDialect: def database_dialect(self) -> DatabaseDialect: ... + @property + @abstractmethod + def is_dialect_confirmed(self) -> bool: + """Whether :attr:`database_dialect` is final, confirmed via a live connection probe or user configuration. + """ + ... + @property @abstractmethod def network_bound_methods(self) -> Set[str]: @@ -352,6 +359,7 @@ def __init__( self._driver_dialect_manager = driver_dialect_manager self._driver_dialect = driver_dialect self._database_dialect = self._dialect_provider.get_dialect(driver_dialect.dialect_code, props) + self._is_dialect_confirmed: bool = self._dialect_provider.is_confirmed_dialect() self._session_state_service = session_state_service if session_state_service is not None else SessionStateServiceImpl(self, props) self._thread_pool = services_container.get_thread_pool(self._executor_name) @@ -513,6 +521,10 @@ def driver_dialect(self) -> DriverDialect: def database_dialect(self) -> DatabaseDialect: return self._database_dialect + @property + def is_dialect_confirmed(self) -> bool: + return self._is_dialect_confirmed + @property def network_bound_methods(self) -> Set[str]: return self._driver_dialect.network_bound_methods @@ -549,11 +561,17 @@ def update_dialect(self, connection: Optional[Connection] = None): self._initial_connection_host_info, connection, self.driver_dialect) + self._is_dialect_confirmed = True - if original_dialect != self._database_dialect: - host_list_provider_init = self._database_dialect.get_host_list_provider_supplier(self) - self.host_list_provider = host_list_provider_init(self, self._props) - self.refresh_host_list(connection) + if original_dialect is not self._database_dialect: + self._update_host_list_provider(connection) + + def _update_host_list_provider(self, connection: Optional[Connection] = None): + """Rebuild the host list provider for the current database dialect. + """ + host_list_provider_init = self._database_dialect.get_host_list_provider_supplier(self) + self.host_list_provider = host_list_provider_init(self, self._props) + self.refresh_host_list(connection) def update_driver_dialect(self, connection_provider: ConnectionProvider): self._driver_dialect = self._driver_dialect_manager.get_pool_connection_driver_dialect( diff --git a/aws_advanced_python_wrapper/resources/aws_advanced_python_wrapper_messages.properties b/aws_advanced_python_wrapper/resources/aws_advanced_python_wrapper_messages.properties index 911056ccf..89652d6e5 100644 --- a/aws_advanced_python_wrapper/resources/aws_advanced_python_wrapper_messages.properties +++ b/aws_advanced_python_wrapper/resources/aws_advanced_python_wrapper_messages.properties @@ -14,9 +14,6 @@ # limitations under the License. -AuroraPgDialect.AuroraUtils=[AuroraPgDialect] aurora_utils: {} -AuroraPgDialect.HasTopologyTrue=[AuroraPgDialect] has_topology: True - AuroraInitialConnectionStrategyPlugin.UnsupportedStrategy=[AuroraInitialConnectionStrategyPlugin] Unsupported host selection strategy '{}'. AuroraInitialConnectionStrategyPlugin.InvalidSettingForEndpoint=[AuroraInitialConnectionStrategyPlugin] Parameter '{}' cannot be set to '{}' when using a {} endpoint. Please see the Aurora Initial Connection Strategy Plugin documentation for valid setting/endpoint combinations. AuroraInitialConnectionStrategyPlugin.InvalidSettingForInstanceEndpoint=[AuroraInitialConnectionStrategyPlugin] Parameter '{}' cannot be set when using an instance endpoint. Please see the Aurora Initial Connection Strategy Plugin documentation for valid setting/endpoint combinations. @@ -29,14 +26,17 @@ AuroraInitialConnectionStrategyPlugin.WaitForTopologyTimeout=[AuroraInitialConne AuroraInitialConnectionStrategyPlugin.FailedToConnectToSelectedInstance=[AuroraInitialConnectionStrategyPlugin] Failed to connect to the selected instance '{}'. Falling back to the connection opened via the provided endpoint. AdfsCredentialsProviderFactory.FailedLogin=[AdfsCredentialsProviderFactory] Failed login. Could not obtain SAML Assertion from ADFS SignOn Page POST response: '{}' -AdfsCredentialsProviderFactory.GetSamlAssertionFailed=[AdfsCredentialsProviderFactory] Failed to get SAML Assertion due to exception: '{}' AdfsCredentialsProviderFactory.SignOnPagePostActionUrl=[AdfsCredentialsProviderFactory] ADFS SignOn Action URL: '{}' -AdfsCredentialsProviderFactory.SignOnPagePostActionRequestFailed=[AdfsCredentialsProviderFactory] ADFS SignOn Page POST action failed with HTTP status '{}', reason phrase '{}', and response '{}' AdfsCredentialsProviderFactory.SignOnPageUrl=[AdfsCredentialsProviderFactory] ADFS SignOn URL: '{}' +AsyncPluginService.DatabaseDialectUpdated=[AsyncPluginService] Database dialect updated: {} -> {}. Rebuilding the host list provider for the new dialect. +AsyncPluginService.DatabaseDialectConfirmed=[AsyncPluginService] Database dialect confirmed: {}. +AsyncPluginService.DatabaseDialectUnconfirmed=[AsyncPluginService] Database dialect probe did not complete, so the dialect stays an unconfirmed guess: {}. Topology queries are deferred and the next connect will retry the probe. AsyncPluginService.HostListProviderNotSet=[AsyncPluginService] Host list provider is not set. Ensure AsyncAwsWrapperConnection.connect has populated it before calling refresh. +AsyncPluginService.HostListProviderReplaced=[AsyncPluginService] Host list provider replaced for dialect {}: {} -> {}. +AsyncPluginService.HostListProviderUnchanged=[AsyncPluginService] Dialect {} supplies the host list provider already in use ({}), so it was kept along with its topology monitor and cached topology. +AsyncPluginService.HostListProviderStopFailed=[AsyncPluginService] Could not stop the previous host list provider while replacing it for the updated database dialect: {} -AwsSdk.UnsupportedRegion=[AwsSdk] Unsupported AWS region {}. Please check AWS documentation for supported AWS regions. AwsSecretsManagerPlugin.ConnectException=[AwsSecretsManagerPlugin] Error occurred while opening a connection: {} AwsSecretsManagerPlugin.EndpointOverrideInvalidConnection=[AwsSecretsManagerPlugin] A connection to the provided secrets_manager_endpoint could not be established: "{}". @@ -47,6 +47,7 @@ AwsSecretsManagerPlugin.JsonDecodeError=[AwsSecretsManagerPlugin] Error occurred AwsSecretsManagerPlugin.MissingRequiredConfigParameter=[AwsSecretsManagerPlugin] Configuration parameter {} is required. AwsSecretsManagerPlugin.UnhandledException=[AwsSecretsManagerPlugin] Unhandled exception: {} +AsyncAwsWrapperConnection.HostListProviderInitialized=[AsyncAwsWrapperConnection] Initialized host list provider {} (cluster id: {}) from database dialect {} (confirmed: {}). AwsWrapperConnection.ConnectionNotOpen=[AwsWrapperConnection] Attempted to establish an initial connection, but the connection returned by the connect call evaluated to None. AwsWrapperConnection.InitialHostInfoNone=[AwsWrapperConnection] The initial connection host info unexpectedly evaluated to None after initializing the host list provider. @@ -56,9 +57,7 @@ BlueGreenRole.UnknownRole=[BlueGreenRole] Unknown blue/green role '{}'. BlueGreenRole.UnknownVersion=[BlueGreenRole] Unknown blue/green version '{}'. BlueGreenStatusMonitor.CreateHostListProvider=[BlueGreenStatusMonitor] [{}] Creating a new HostListProvider, clusterId: {}. -BlueGreenStatusMonitor.Exception=[BlueGreenStatusMonitor] [{}] currentPhase: {}, exception while querying for blue green status. BlueGreenStatusMonitor.HostInfoNone=[BlueGreenStatusMonitor] Unable to initialize HostListProvider since connection host information is None. -BlueGreenStatusMonitor.Interrupted=[BlueGreenStatusMonitor] [{}] Interrupted. BlueGreenStatusMonitor.MonitoringUnhandledException=[BlueGreenStatusMonitor] [{}] Unhandled exception while monitoring blue/green status: {}. BlueGreenStatusMonitor.NoEntriesInStatusTable=[BlueGreenStatusMonitor] [{}] No entries in status table. BlueGreenStatusMonitor.OpenedConnection=[BlueGreenStatusMonitor] [{}] Opened monitoring connection to {}. @@ -84,7 +83,6 @@ BlueGreenStatusProvider.SwitchoverTimeout=[BlueGreenStatusProvider] Blue/Green s BlueGreenStatusProvider.UnknownPhase=[BlueGreenStatusProvider] [bgdId: '{}'] Unknown BG phase '{}'. BlueGreenStatusProvider.UnsupportedDialect=[BlueGreenStatusProvider] [bgdId: '{}'] Blue/Green Deployments isn't supported by database dialect {}. -CloseConnectionExecuteRouting.InProgressConnectionClosed=[CloseConnectionExecuteRouting] Connection has been closed since Blue/Green switchover is in progress. ClusterTopologyMonitor.StartMonitoringThread=[ClusterTopologyMonitor, clusterId: '{}'] Starting cluster topology monitoring thread for '{}'. ClusterTopologyMonitor.StopMonitoringThread=[ClusterTopologyMonitor, clusterId: '{}'] Stop cluster topology monitoring thread for '{}'. @@ -162,7 +160,6 @@ DriverConnectionProvider.ConnectingToHost=[DriverConnectionProvider] Connecting DriverConnectionProvider.UnsupportedStrategy=[DriverConnectionProvider] DriverConnectionProvider does not support strategy: {} DriverDialect.ExecuteTimeout=[DriverDialect] The timeout limit was reached while executing '{}'. -DriverDialect.InvalidTargetAttribute=[DriverDialect] [{}] Could not find a valid target connection attribute for the '{}' method DriverDialect.UnsupportedOperationError=[DriverDialect] target driver '{}' dialect doesn't support '{}'. DriverDialectManager.CustomDialectNotSupported=[DriverDialectManager] The provided custom target driver dialect is not compatible with the given target connect function and will thus be ignored. @@ -189,9 +186,7 @@ FailoverPlugin.UnableToConnectToReader=[Failover] Unable to establish SQL connec FailoverPlugin.UnableToConnectToWriter=[Failover] Unable to establish SQL connection to the writer instance. FailoverPlugin.ExceptionConnectingToWriter=[Failover] Unable to establish SQL connection to the writer instance. Exception: {}. FailoverPlugin.UnableToRefreshHostList=[Failover] Unable to refresh host list. -FailoverPlugin.FoundWriterCandidate=[Failover] Found writer candidate: {} FailoverPlugin.NoWriterHostInTopology=[Failover] No writer host found in topology: {} -FailoverPlugin.WriterFailoverConnectedToReader=[Failover] Writer failover unexpectedly connected to a reader: {} FailoverPlugin.WriterFailoverTime=[Failover] Writer failover elapsed: {}ms. FailoverPlugin.ReaderFailoverTime=[Failover] Reader failover elapsed: {}ms. @@ -235,6 +230,8 @@ HostAvailabilityStrategy.InvalidInitialBackoffTime=[HostAvailabilityStrategy] In HostAvailabilityStrategy.InvalidMaxRetries=[HostAvailabilityStrategy] Invalid value of {} for configuration parameter `host_availability_strategy_max_retries`. It must be an integer greater than 1. HostListProvider.ForceMonitoringRefreshUnsupported=[{}] Force monitoring refresh is not supported. +HostListProvider.MonitorConnectionNoSeedHost=[{}] The topology monitor could not open its own connection because no seed host is known yet. +HostListProvider.MonitorConnectionNoTargetDriverFunc=[{}] The topology monitor could not open its own connection because the target driver function was never set on the host list provider service. HostMonitor.DetectedWriter=[HostMonitor] Writer detected by host monitoring thread: {}. HostMonitor.InvalidWriterQuery=[HostMonitor] The writer topology query is invalid: {} @@ -274,14 +271,13 @@ HostMonitorV2.HostDead=[HostMonitorV2] Host '{}' is *dead*. HostMonitorV2.HostNotResponding=[HostMonitorV2] Host '{}' is not *responding* '{}'. HostMonitorV2.HostAlive=[HostMonitorV2] Host '{}' is *alive*. HostMonitorV2.StopMonitoringThread=[HostMonitorV2] Stop monitoring thread for '{}'. +HostMonitorV2.ResetEventReceived=[HostMonitorV2] MonitorResetEvent received for host '{}'. MonitorServiceV2.ExceptionAbortingConnection=[MonitorServiceV2] Exception during aborting connection: '{}' HostSelector.NoEligibleHost=[HostSelector] No Eligible Hosts Found. HostSelector.NoHostsMatchingRole=[HostSelector] No hosts were found matching the requested role: '{}'. IamAuthPlugin.ConnectException=[IamAuthPlugin] Error occurred while opening a connection: {} -IamAuthPlugin.InvalidPort=[IamAuthPlugin] Port number: {} is not valid. Port number should be greater than zero. Falling back to default port. -IamAuthPlugin.NoValidPort=[IamAuthPlugin] Unable to determine a valid port. IamAuthPlugin.UnhandledException=[IamAuthPlugin] Unhandled exception: {} IamAuthPlugin.UseCachedIamToken=[IamAuthPlugin] Used cached IAM token = {} IamAuthPlugin.InvalidHost=[IamAuthPlugin] Invalid IAM host {}. The IAM host must be a valid RDS or Aurora endpoint. @@ -295,9 +291,7 @@ LimitlessQueryHelper.UnsupportedDialectOrDatabase=[LimitlessQueryHelper] Unsuppo LimitlessRouterMonitor.errorDuringMonitoringStop=[LimitlessRouterMonitor] Stopping monitoring after unhandled error was thrown in Limitless Router Monitoring thread for host {}. Error: {} LimitlessRouterMonitor.InterruptedErrorDuringMonitoring=[LimitlessRouterMonitor] Limitless Router Monitoring thread for host {} was interrupted. -LimitlessRouterMonitor.InvalidQuery=[LimitlessRouterMonitor] Limitless Connection Plugin has encountered an error obtaining Limitless Router endpoints. Please ensure that you are connecting to an Aurora Limitless Database Shard Group Endpoint URL. LimitlessRouterMonitor.InvalidRouterLoad=[LimitlessRouterMonitor] Invalid load metric value of '{}' from the transaction router query aurora_limitless_router_endpoints() for transaction router '{}'. The load metric value must be a decimal value between 0 and 1. Host weight be assigned a default weight of 1. -LimitlessRouterMonitor.GetNetworkTimeoutError=[LimitlessRouterMonitor] An error occurred while getting the connection network timeout: {} LimitlessRouterMonitor.OpeningConnection=[LimitlessRouterMonitor] Opening Limitless Router Monitor connection to '{}'. LimitlessRouterMonitor.OpenedConnection=[LimitlessRouterMonitor] Opened Limitless Router Monitor connection: {}. LimitlessRouterMonitor.Running=[LimitlessRouterMonitor] Limitless Router Monitor thread running on host {}. @@ -308,7 +302,6 @@ LimitlessRouterService.ErrorClosingMonitor=[LimitlessRouterService] An error occ LimitlessRouterService.ErrorStartingMonitor=[LimitlessRouterService] An error occurred while starting Limitless Router Monitor: {} LimitlessRouterService.FailedToConnectToHost=[LimitlessRouterService] Failed to connect to host {}. LimitlessRouterService.FetchedEmptyRouterList=[LimitlessRouterService] Empty router list was fetched. -LimitlessRouterService.GetLimitlessRoutersError=[LimitlessRouterService] error encountered getting Limitless Routers. {} LimitlessRouterService.IncorrectConfiguration=[LimitlessRouterService] Limitless Connection Plugin is unable to run. Please ensure the connection settings are correct. LimitlessRouterService.InterruptedSynchronousGetRouter=[LimitlessRouterService] Limitless Router Service thread was interrupted while waiting to fetch Limitless Transaction Routers. LimitlessRouterService.LimitlessRouterCacheEmpty=[LimitlessRouterService] Limitless Router cache is empty. This is normal during application start up when the cache is not yet populated. @@ -333,16 +326,11 @@ Monitor.OpeningMonitorConnection=[Monitor] Opening a monitoring connection to '{ Monitor.ResetEventReceived=[Monitor] MonitorResetEvent received for host '{}'. MonitorContext.ExceptionAbortingConnection=[MonitorContext] An exception occurred while attempting to abort the monitored connection: '{}'. -MonitorContext.HostAvailable=[MonitorContext] Host '{}' is *available*. MonitorContext.HostNotResponding=[MonitorContext] Host '{}' is not *responding* - failure count: '{}'. MonitorContext.HostUnavailable=[MonitorContext] Host '{}' is *unavailable*. -MonitoringThreadContainer.EmptyHostKeys=[MonitorThreadContainer] The provided host_aliases set was empty. -MonitoringThreadContainer.ErrorGettingMonitor=[MonitorThreadContainer] Unable to find or create monitor for host with aliases '{}'. -MonitoringThreadContainer.SupplierMonitorNone=[MonitorThreadContainer] The monitor supplier passed into get_or_create_monitor returned None. MonitorService.EmptyAliasSet=[MonitorService] Empty alias set passed for '{}'. The alias set should not be empty. -MonitorService.ErrorPopulatingAliases=[MonitorService] An error occurred while populating aliases: '{}'. GDBRegionUtils.UnableToRetrieveGlobalClusterARN=Unable to retrieve the primary global region for the provided global database cluster. @@ -364,7 +352,6 @@ HostResponseTimeMonitor.ResponseTime=[HostResponseTimeMonitor] Response time for HostResponseTimeMonitor.Stopped=[HostResponseTimeMonitor] Stopped Response time thread for host '{}'. HostResponseTimeMonitor.ResetEventReceived=[HostResponseTimeMonitor] MonitorResetEvent received for host '{}'. -ThreadPoolContainer.ErrorShuttingDownPool=[ThreadPoolContainer] Error shutting down pool '{}': '{}'. OpenedConnectionTracker.OpenedConnectionsTracked=[OpenedConnectionTracker] Opened Connections Tracked: {} OpenedConnectionTracker.InvalidatingConnections=[OpenedConnectionTracker] Invalidating opened connections to host: {} @@ -392,12 +379,10 @@ PluginServiceImpl.FailedToRetrieveHostPort=[PluginServiceImpl] Could not retriev PluginServiceImpl.FillAliasesTimeout=[PluginServiceImpl] The timeout limit was reached while querying for the current host's alias. PluginServiceImpl.GetHostRoleConnectionNone=[PluginServiceImpl] Attempted to evaluate the host role of the given connection, but could not find a non-None connection to evaluate. PluginServiceImpl.HostListEmpty=[PluginServiceImpl] Could not determine the current host info because the current host list is empty. -PluginServiceImpl.IncorrectStatusType=[PluginServiceImpl] Received an unexpected type from the status cache. An object of type {} was requested, but the object at key '{}' had a type of {}. The retrieved object was: {}. PluginServiceImpl.NonEmptyAliases=[PluginServiceImpl] fill_aliases called when HostInfo already contains the following aliases: {}. PluginServiceImpl.SetCurrentHostInfo=[PluginServiceImpl] Set current host info to {} PluginServiceImpl.UnableToUpdateTransactionStatus=[PluginServiceImpl] Unable to update transaction status, current connection is None. PluginServiceImpl.UpdateDialectConnectionNone=[PluginServiceImpl] The plugin service attempted to update the current dialect but could not identify a connection to use. -PluginServiceImpl.UnsupportedStrategy=[PluginServiceImpl] The driver does not support the requested host selection strategy: {} PluginServiceImpl.ErrorIdentifyConnection=[PluginServiceImpl] An error occurred while obtaining the connection's host ID. PluginServiceImpl.IdentifyConnectionTimeout=[PluginServiceImpl] The timeout limit was reached while querying for the current host's ID. @@ -411,11 +396,9 @@ RdsHostListProvider.ClusterInstanceHostPatternNotSupportedForRDSProxy=[RdsHostLi RdsHostListProvider.InvalidPattern=[RdsHostListProvider] Invalid value for the 'cluster_instance_host_pattern' configuration setting - the host pattern must contain a '?' character as a placeholder for the DB instance identifiers of the instances in the cluster. RdsHostListProvider.InvalidQuery=[RdsHostListProvider] Error obtaining host list. Provided database might not be an Aurora Db cluster RdsHostListProvider.InvalidTopology=[RdsHostListProvider] The topology query returned an invalid topology - no writer instance detected. -RdsHostListProvider.QueryForTopologyTimeout=[RdsHostListProvider] The timeout limit was reached while querying for the database topology. RdsHostListProvider.UninitializedClusterInstanceTemplate=[RdsHostListProvider] The driver was unable to build a topology object because the cluster instance template was never initialized. RdsHostListProvider.UninitializedInitialHostInfo=[RdsHostListProvider] The driver was unable to build a topology object because the initial host info was never initialized. -RdsPgDialect.RdsToolsAuroraUtils=[RdsPgDialect] rds_tools: {}, aurora_utils: {} RdsTestUtility.ClusterMemberNotFound=[RdsTestUtility] Cannot find cluster member whose db instance identifier is '{}'. RdsTestUtility.CreateDBInstanceFailed=[RdsTestUtility] Could not create database instance '{}'. @@ -441,7 +424,6 @@ ReadWriteSplittingPlugin.ErrorSwitchingToReader=[ReadWriteSplittingPlugin] An er ReadWriteSplittingPlugin.ErrorSwitchingToWriter=[ReadWriteSplittingPlugin] An error occurred while trying to switch to a writer connection. ReadWriteSplittingPlugin.ErrorVerifyingInitialHostSpecRole=[ReadWriteSplittingPlugin] An error occurred while obtaining the connected host's role. This could occur if the connection is broken or if you are not connected to an Aurora database. ReadWriteSplittingPlugin.ExceptionWhileExecutingCommand=[ReadWriteSplittingPlugin] Detected an exception while executing a command: '{}' -ReadWriteSplittingPlugin.ExecutingAgainstOldConnection=[ReadWriteSplittingPlugin] Executing method against old connection: '{}' ReadWriteSplittingPlugin.FailedToConnectToReader=[ReadWriteSplittingPlugin] Failed to connect to reader host: '{}' ReadWriteSplittingPlugin.FailedToConnectToWriter=[ReadWriteSplittingPlugin] Failed to connect to writer host: '{}' ReadWriteSplittingPlugin.FailoverExceptionWhileExecutingCommand=[ReadWriteSplittingPlugin] Detected a failover exception while executing a command: '{}' @@ -526,12 +508,10 @@ Testing.RequiredTestDriver=[Testing] testDriver is required. Testing._get_multi_az_instance_ids=[Testing] Get topology: {}. Testing._get_multi_az_instance_ids_connecting=[Testing] Connecting to {}. -UnknownDialect.AbortConnection=[UnknownDialect] abort_connection was called, but the database dialect is unknown. A valid database dialect must be detected in order to abort a connection. UnknownDialect.UnsupportedMethod = [UnknownDialect] UnknownDialect does not support {}. Wrapper.ConnectMethod=[Wrapper] Target driver should be a target driver's connect() method/function. Wrapper.RequiredTargetDriver=[Wrapper] Target driver is required. -Wrapper.UnsupportedAttribute=[Wrapper] Target driver does not have the attribute: '{}' Wrapper.Properties=[Wrapper] "Connection Properties: {}" WriterFailoverHandler.AlreadyWriter=[WriterFailoverHandler] Current reader connection is actually a new writer connection. @@ -557,8 +537,6 @@ XRayTelemetryContext.TraceID="[XRayTelemetryContext] Telemetry '{}' trace ID: {} XRayTelemetryFactory.MetricsNotSupported="[XRayTelemetryFactory] XRay doesn't support metrics." XRayTelemetryFactory.WrongParameterType="[XRayTelemetryFactory] Wrong parameter type: {}" -SlidingExpirationCacheContainer.ErrorReleasingCache=[SlidingExpirationCacheContainer] Error releasing cache '{}': {} -SlidingExpirationCacheContainer.ErrorDuringCleanup=[SlidingExpirationCacheContainer] Error during cleanup of cache '{}': {} BatchingEventPublisher.ErrorDeliveringEvent=[BatchingEventPublisher] Error delivering event: {} diff --git a/tests/integration/container/test_failover_performance.py b/tests/integration/container/test_failover_performance.py index 4a242dbc6..ec13cc8f8 100644 --- a/tests/integration/container/test_failover_performance.py +++ b/tests/integration/container/test_failover_performance.py @@ -253,7 +253,7 @@ def _open_connect_with_retry(self, connect_func, connect_params: Dict[str, Any], **connect_params, **props) except Exception as e: - TestPerformance.logger.debug("OpenConnectionFailed", str(e)) + TestPerformance.logger.debug("Testing.OpenConnectionFailed", str(e)) connection_attempts += 1 if conn is None: diff --git a/tests/integration/container/test_failover_performance_async.py b/tests/integration/container/test_failover_performance_async.py index b7ad49e32..8e361ac0a 100644 --- a/tests/integration/container/test_failover_performance_async.py +++ b/tests/integration/container/test_failover_performance_async.py @@ -287,7 +287,7 @@ async def _open_connect_with_retry_async( connect_params=connect_params, **dict(props)) except Exception as e: - TestPerformanceAsync.logger.debug("OpenConnectionFailed", str(e)) + TestPerformanceAsync.logger.debug("Testing.OpenConnectionFailed", str(e)) connection_attempts += 1 if conn is None: diff --git a/tests/unit/test_aio_aurora_connection_tracker.py b/tests/unit/test_aio_aurora_connection_tracker.py index 9056d9597..c8c87c4a1 100644 --- a/tests/unit/test_aio_aurora_connection_tracker.py +++ b/tests/unit/test_aio_aurora_connection_tracker.py @@ -87,6 +87,48 @@ async def _run(): assert conn in tracked +@pytest.mark.parametrize("port", [3306, 5432, 8666]) +def test_host_list_change_invalidates_a_demoted_writer_on_any_port(port): + """Host-list-change keys are HostInfo.url ("host:port/"). The trailing + slash used to break the port parse back to 5432, so a rebuilt key never + matched the tracked one and a demoted MySQL writer's connections were + never invalidated.""" + plugin, svc, driver_dialect, tracker = _build() + host = HostInfo(host="instance-1.abc.us-east-1.rds.amazonaws.com", + port=port, role=HostRole.WRITER) + conn = MagicMock(name="conn") + + async def _connect_func(): + return conn + + async def _run(): + await plugin.connect( + target_driver_func=MagicMock(), + driver_dialect=driver_dialect, + host_info=host, + props=svc.props, + is_initial_connection=True, + connect_func=_connect_func) + assert conn in tracker._tracked_for(host) + + # The host the demotion path rebuilds from the published url key has + # to resolve to the connection that connect() tracked. The trailing + # slash used to force the port back to 5432, so on MySQL the rebuilt + # host found nothing and the connection survived the demotion. + rebuilt_host, rebuilt_port = plugin._parse_alias(host.url) + assert (rebuilt_host, rebuilt_port) == (host.host, port) + rebuilt = HostInfo(host=rebuilt_host, port=rebuilt_port) + assert conn in tracker._tracked_for(rebuilt) + + asyncio.run(_run()) + + +def test_parse_alias_keeps_the_port_from_a_url_key(): + parsed = AsyncAuroraConnectionTrackerPlugin._parse_alias( + "instance-1.abc.us-east-1.rds.amazonaws.com:3306/") + assert parsed == ("instance-1.abc.us-east-1.rds.amazonaws.com", 3306) + + def test_connect_enriches_cluster_endpoint_with_resolved_instance_alias(): """A cluster-endpoint connection must be tracked under the resolved *instance* alias so writer-failover ``invalidate_all(instance)`` closes diff --git a/tests/unit/test_aio_dialect_provider_logging.py b/tests/unit/test_aio_dialect_provider_logging.py new file mode 100644 index 000000000..1af092d02 --- /dev/null +++ b/tests/unit/test_aio_dialect_provider_logging.py @@ -0,0 +1,233 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +# You may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the async dialect / host-list-provider debug logging. + +These logs are how a failover investigation establishes which dialect and +which provider a connection actually ended up with, so they have to render +real values. A message whose placeholder count disagrees with its call site +silently logs a template fragment -- ``Messages.get_formatted`` does not +raise on a count mismatch -- so the rendering is asserted here rather than +left to a manual read of the properties file. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any, Optional +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from aws_advanced_python_wrapper.aio.plugin_service import \ + AsyncPluginServiceImpl +from aws_advanced_python_wrapper.database_dialect import (AuroraMysqlDialect, + AuroraPgDialect, + DialectCode, + RdsMysqlDialect) +from aws_advanced_python_wrapper.utils.messages import Messages +from aws_advanced_python_wrapper.utils.properties import (Properties, + WrapperProperties) +from aws_advanced_python_wrapper.utils.rds_utils import RdsUtils + +# Every key added for dialect / provider visibility, with the argument count +# its call site passes. +KEYS_AND_ARG_COUNTS = [ + ("AsyncAwsWrapperConnection.HostListProviderInitialized", 4), + ("AsyncPluginService.DatabaseDialectUpdated", 2), + ("AsyncPluginService.DatabaseDialectConfirmed", 1), + ("AsyncPluginService.DatabaseDialectUnconfirmed", 1), + ("AsyncPluginService.HostListProviderReplaced", 3), + ("AsyncPluginService.HostListProviderStopFailed", 1), + ("HostListProvider.MonitorConnectionNoSeedHost", 1), + ("HostListProvider.MonitorConnectionNoTargetDriverFunc", 1), +] + + +@pytest.mark.parametrize("key,arg_count", KEYS_AND_ARG_COUNTS) +def test_message_placeholder_count_matches_call_site(key: str, arg_count: int) -> None: + template = Messages.get(key) + assert template.count("{}") == arg_count, ( + f"{key} has {template.count('{}')} placeholders but its call site " + f"passes {arg_count} arguments") + rendered = Messages.get_formatted(key, *[f"arg{i}" for i in range(arg_count)]) + assert "{}" not in rendered + + +def _mysql_service(dialect: Any, dialect_code: Any) -> AsyncPluginServiceImpl: + from aws_advanced_python_wrapper.database_dialect import \ + DatabaseDialectManager + + dd = MagicMock() + dd._driver_name = "aiomysql" + dd.is_in_transaction = AsyncMock(return_value=False) + props = Properties({"host": "instance-1.xyz.us-east-2.rds.amazonaws.com", "port": "3306"}) + svc = AsyncPluginServiceImpl(props, dd) + svc.database_dialect = dialect + + # A manager mid-resolution, which is the state the wrapper hands over: + # a pattern-based guess live verification may still replace. + manager = DatabaseDialectManager(props) + manager._dialect = dialect + manager._dialect_code = dialect_code + manager._can_update = True + manager._confirmed = False + svc.database_dialect_manager = manager + return svc + + +def _aurora_verify_connection(is_aurora: bool = True) -> Any: + """A connection satisfying the Aurora rules, and answering the Global + Aurora candidate's queries with "no" so the walk settles on Aurora.""" + responses = { + "aurora_version": [("aurora_version", "3.0")] if is_aurora else [], + # Not a global database, and not Multi-AZ. + "AURORA_GLOBAL_DB_STATUS": [], + "AURORA_GLOBAL_DB_INSTANCE_STATUS": [], + "replica_host_status": [], + } + state = {"query": ""} + + def rows() -> list: + for fragment, response in responses.items(): + if fragment in state["query"]: + return list(response) + return [] + + async def execute(query: str, *args: Any, **kwargs: Any) -> None: + state["query"] = query + + cursor = MagicMock(name="cursor") + cursor.execute = AsyncMock(side_effect=execute) + cursor.fetchall = AsyncMock(side_effect=lambda: rows()) + cursor.fetchone = AsyncMock(side_effect=lambda: next(iter(rows()), None)) + cursor.close = MagicMock() + conn = MagicMock(name="conn") + conn.cursor = MagicMock(return_value=cursor) + return conn + + +def _run_and_capture(svc: AsyncPluginServiceImpl, conn: Any, caplog) -> str: + with caplog.at_level( + logging.DEBUG, logger="aws_advanced_python_wrapper.aio.plugin_service"): + asyncio.run(svc.update_database_dialect(conn)) + return caplog.text + + +def test_dialect_upgrade_and_provider_replacement_are_logged(caplog) -> None: + from aws_advanced_python_wrapper.aio.host_list_provider import \ + AsyncStaticHostListProvider + + svc = _mysql_service(RdsMysqlDialect(), DialectCode.RDS_MYSQL) + svc.host_list_provider = AsyncStaticHostListProvider(svc.props) + + text = _run_and_capture(svc, _aurora_verify_connection(), caplog) + + # Both dialect names must appear, not a bare template. + assert "RdsMysqlDialect -> AuroraMysqlDialect" in text + assert "AsyncStaticHostListProvider -> AsyncAuroraHostListProvider" in text + assert "{}" not in text + + +def test_unchanged_dialect_logs_confirmation_not_upgrade(caplog) -> None: + # A cluster endpoint already resolved to AuroraMysqlDialect by pattern: + # verification confirms it, nothing is rebuilt. + svc = _mysql_service(AuroraMysqlDialect(), DialectCode.AURORA_MYSQL) + + text = _run_and_capture(svc, _aurora_verify_connection(), caplog) + + assert "Database dialect confirmed" in text + assert "AuroraMysqlDialect" in text + assert "->" not in text + assert "{}" not in text + + +def _resolved_cluster_id(props: Properties) -> str: + """The cluster id the connect-time log reports. + + Mirrors the resolution in ``AsyncAwsWrapperConnection._connect_pipeline``: + the explicit property wins, else the identifier carried by an RDS cluster + endpoint. Deliberately NOT the provider's ``get_cluster_id()``, which is a + cache key and degrades to "aurora::" -- the endpoint restated. + """ + cluster_id = (props.get(WrapperProperties.CLUSTER_ID.name) + or RdsUtils().get_cluster_id(props.get("host", ""))) + return cluster_id or "" + + +@pytest.mark.parametrize("host,expected", [ + ("db.cluster-xyz.us-east-2.rds.amazonaws.com", "db"), + # A reader endpoint belongs to the same cluster. + ("db.cluster-ro-xyz.us-east-2.rds.amazonaws.com", "db"), + # An instance endpoint carries no cluster identity, and must not be + # reported as though it were one. + ("instance-1.xyz.us-east-2.rds.amazonaws.com", ""), + ("localhost", ""), +]) +def test_logged_cluster_id_is_the_cluster_not_the_endpoint(host: str, expected: str) -> None: + props = Properties({"host": host, "port": "5432"}) + resolved = _resolved_cluster_id(props) + assert resolved == expected + # Whatever is reported, it is never the endpoint or the cache key built + # from it. + assert not resolved.startswith("aurora:") + assert host not in resolved + + +def test_explicit_cluster_id_property_wins() -> None: + props = Properties({ + "host": "db.cluster-xyz.us-east-2.rds.amazonaws.com", + "port": "5432", + WrapperProperties.CLUSTER_ID.name: "my-cluster", + }) + assert _resolved_cluster_id(props) == "my-cluster" + + +def test_provider_initialization_log_renders_dialect_and_cluster_id(caplog) -> None: + """The connect-time log is what confirms which provider a connection got.""" + from aws_advanced_python_wrapper.aio.driver_dialect.psycopg import \ + AsyncPsycopgDriverDialect + + class _Service: + def __init__(self) -> None: + self.driver_dialect = AsyncPsycopgDriverDialect() + self.is_dialect_confirmed = True + self.initial_connection_host_info = None + self.current_host_info = None + + def get_target_driver_func(self) -> Optional[Any]: + return None + + props = Properties({"host": "db.cluster-xyz.us-east-2.rds.amazonaws.com", "port": "5432"}) + dialect = AuroraPgDialect() + provider = dialect.get_async_host_list_provider_supplier()(_Service(), props) + + logger_name = "aws_advanced_python_wrapper.aio.wrapper" + with caplog.at_level(logging.DEBUG, logger=logger_name): + from aws_advanced_python_wrapper.utils.log import Logger + Logger(logger_name).debug( + "AsyncAwsWrapperConnection.HostListProviderInitialized", + type(provider).__name__, + _resolved_cluster_id(props), + type(dialect).__name__, + True) + + assert "AsyncAuroraHostListProvider" in caplog.text + assert "AuroraPgDialect" in caplog.text + assert "cluster id: db)" in caplog.text + # The endpoint-derived cache key must not leak into the line. + assert "aurora:db.cluster-xyz" not in caplog.text + assert "confirmed: True" in caplog.text + assert "{}" not in caplog.text diff --git a/tests/unit/test_aio_host_list_provider.py b/tests/unit/test_aio_host_list_provider.py index 8cf91b0b2..939676979 100644 --- a/tests/unit/test_aio_host_list_provider.py +++ b/tests/unit/test_aio_host_list_provider.py @@ -20,7 +20,7 @@ import time from datetime import datetime from typing import Any, List -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -319,35 +319,57 @@ async def _body() -> None: # ---- _validate_host_pattern (sync parity) ----------------------------- +# +# The pattern is resolved and validated in __init__ (sync parity: +# TopologyUtils.__init__, host_list_provider.py:456-476), so an invalid +# pattern is rejected at construction rather than on the first topology +# refresh -- a misconfiguration fails before the connect instead of +# surfacing later as an empty host list. + + def test_validate_rejects_pattern_without_placeholder(): - prov = AsyncAuroraHostListProvider( - _props(cluster_instance_host_pattern="no-placeholder.example.com"), - driver_dialect=MagicMock()) with pytest.raises(AwsWrapperError, match="must contain"): - prov._resolve_instance_pattern() + AsyncAuroraHostListProvider( + _props(cluster_instance_host_pattern="no-placeholder.example.com"), + driver_dialect=MagicMock()) def test_validate_rejects_rds_proxy_pattern(): - prov = AsyncAuroraHostListProvider( - _props(cluster_instance_host_pattern="?.proxy-abc123.us-east-1.rds.amazonaws.com"), - driver_dialect=MagicMock()) with pytest.raises(AwsWrapperError, match="RDS Proxy"): - prov._resolve_instance_pattern() + AsyncAuroraHostListProvider( + _props(cluster_instance_host_pattern="?.proxy-abc123.us-east-1.rds.amazonaws.com"), + driver_dialect=MagicMock()) def test_validate_rejects_rds_custom_pattern(): - prov = AsyncAuroraHostListProvider( - _props(cluster_instance_host_pattern="?.cluster-custom-abc123.us-east-1.rds.amazonaws.com"), - driver_dialect=MagicMock()) with pytest.raises(AwsWrapperError, match="RDS Custom"): - prov._resolve_instance_pattern() + AsyncAuroraHostListProvider( + _props(cluster_instance_host_pattern="?.cluster-custom-abc123.us-east-1.rds.amazonaws.com"), + driver_dialect=MagicMock()) def test_validate_accepts_normal_cluster_pattern(): prov = _aurora() - host_pattern, port = prov._resolve_instance_pattern() - assert host_pattern == "?.cluster-xyz.us-east-1.rds.amazonaws.com" - assert port is None + assert prov._instance_host_pattern == "?.cluster-xyz.us-east-1.rds.amazonaws.com" + assert prov._instance_pattern_port is None + + +def test_instance_pattern_is_resolved_once_not_per_topology_row(): + """The pattern feeds every topology row, and resolving it builds + RdsUtils and runs several regex passes, so it must not sit on the + per-row path.""" + async def _body() -> None: + conn = _build_conn([("instance-1", True), ("instance-2", False), + ("instance-3", False)]) + prov = _aurora() + with patch.object( + AsyncAuroraHostListProvider, "_resolve_instance_pattern", + wraps=prov._resolve_instance_pattern) as resolve: + topo = await prov.force_refresh(conn) + assert len(topo) == 3 + resolve.assert_not_called() + + asyncio.run(_body()) # ---- provider.stop() tears down the monitor --------------------------- diff --git a/tests/unit/test_aio_host_list_provider_autodetect.py b/tests/unit/test_aio_host_list_provider_autodetect.py index a3bf69968..79663d74c 100644 --- a/tests/unit/test_aio_host_list_provider_autodetect.py +++ b/tests/unit/test_aio_host_list_provider_autodetect.py @@ -12,101 +12,148 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Tests for auto-detection of MultiAz/GlobalAurora host-list providers (N.1).""" +"""Tests for dialect-driven selection of the async host list provider. + +Each dialect names its provider through +``get_async_host_list_provider_supplier``, which the async wrapper invokes +during connect. These tests call the suppliers the way the wrapper does, +pinning the dialect -> provider mapping and the per-dialect topology SQL +and default port that come with it. +""" from __future__ import annotations +from typing import Any, Callable, Optional + +import pytest + +from aws_advanced_python_wrapper.aio.driver_dialect.aiomysql import \ + AsyncAiomysqlDriverDialect from aws_advanced_python_wrapper.aio.driver_dialect.psycopg import \ AsyncPsycopgDriverDialect from aws_advanced_python_wrapper.aio.host_list_provider import ( AsyncAuroraHostListProvider, AsyncGlobalAuroraHostListProvider, AsyncMultiAzHostListProvider, AsyncStaticHostListProvider) -from aws_advanced_python_wrapper.aio.wrapper import _build_host_list_provider +from aws_advanced_python_wrapper.database_dialect import ( + AuroraMysqlDialect, AuroraPgDialect, GlobalAuroraMysqlDialect, + GlobalAuroraPgDialect, MultiAzClusterMysqlDialect, MultiAzClusterPgDialect, + MysqlDatabaseDialect, PgDatabaseDialect, RdsMysqlDialect, RdsPgDialect, + UnknownDatabaseDialect) +from aws_advanced_python_wrapper.hostinfo import HostInfo from aws_advanced_python_wrapper.utils.properties import Properties +MYSQL_PORT = 3306 +PG_PORT = 5432 -def test_no_topology_plugins_returns_static() -> None: - dd = AsyncPsycopgDriverDialect() - provider = _build_host_list_provider(Properties(), dd) - assert isinstance(provider, AsyncStaticHostListProvider) +class _FakeProviderService: + """Minimal AsyncHostListProviderService for provider construction. -def test_topology_plugins_without_dialect_return_aurora() -> None: - dd = AsyncPsycopgDriverDialect() - provider = _build_host_list_provider( - Properties({"plugins": "failover"}), dd) - assert isinstance(provider, AsyncAuroraHostListProvider) - # Subclasses like MultiAz are a "real" Aurora-derived type; verify - # we got the plain base not a subclass. - assert type(provider) is AsyncAuroraHostListProvider + The suppliers only read ``driver_dialect`` while building; the seed + host and target driver function are resolved lazily, when the topology + monitor opens its own connection. + """ + def __init__(self, driver_dialect: Any, is_dialect_confirmed: bool = True) -> None: + self.driver_dialect = driver_dialect + self.is_dialect_confirmed = is_dialect_confirmed + self.current_connection: Optional[Any] = None + self.current_host_info: Optional[HostInfo] = None + self.initial_connection_host_info: Optional[HostInfo] = None + self.database_dialect: Optional[Any] = None + self.host_list_provider: Optional[Any] = None -def test_gdb_failover_plugin_returns_topology_provider() -> None: - # Regression: gdb_failover MUST be treated as a topology-requiring plugin. - # It was omitted from _TOPOLOGY_REQUIRING_PLUGINS (gdb_rw was present but - # gdb_failover was not), so a gdb_failover connection got the static - # provider -- a single seed host, no topology query, no cluster topology - # monitor. Writer failover then had only the seed host and looped on the - # demoted old writer until timeout (never discovering the new writer), on - # both PG and MySQL. A topology provider (with monitor) is required. - dd = AsyncPsycopgDriverDialect() - provider = _build_host_list_provider( - Properties({"plugins": "gdb_failover"}), dd) - assert not isinstance(provider, AsyncStaticHostListProvider) - assert isinstance(provider, AsyncAuroraHostListProvider) - + def is_static_host_list_provider(self) -> bool: + return isinstance(self.host_list_provider, AsyncStaticHostListProvider) -def test_multi_az_dialect_selects_multi_az_provider() -> None: - dd = AsyncPsycopgDriverDialect() + def get_target_driver_func(self) -> Optional[Callable]: + return None - class MultiAzClusterPgDialect: - _WRITER_HOST_QUERY = "SELECT writer FROM multi_az" - _TOPOLOGY_QUERY = "SELECT id, host, port FROM multi_az" - _HOST_ID_QUERY = "SELECT aurora_db_instance_identifier()" - provider = _build_host_list_provider( - Properties({"plugins": "failover"}), dd, MultiAzClusterPgDialect()) - assert isinstance(provider, AsyncMultiAzHostListProvider) +def _build(dialect: Any, driver_dialect: Any = None, props: Optional[Properties] = None, + service: Optional[Any] = None) -> Any: + """Invoke a dialect's supplier the way AsyncAwsWrapperConnection.connect does.""" + if service is None: + service = _FakeProviderService( + driver_dialect if driver_dialect is not None else AsyncPsycopgDriverDialect()) + return dialect.get_async_host_list_provider_supplier()( + service, props if props is not None else Properties()) -def test_multi_az_dialect_missing_queries_falls_back_to_aurora() -> None: - """Defensive: if a dialect has the MultiAz naming but lacks the - required query strings, fall back to plain Aurora rather than - raising on init.""" - dd = AsyncPsycopgDriverDialect() +@pytest.mark.parametrize("dialect", [ + PgDatabaseDialect(), + MysqlDatabaseDialect(), + RdsPgDialect(), + RdsMysqlDialect(), + UnknownDatabaseDialect(), +]) +def test_no_topology_dialects_return_static(dialect) -> None: + # These dialects have no topology query, so there is nothing for a + # topology provider to run. Mirrors the sync suppliers, which all + # return ConnectionStringHostListProvider for these dialects. + assert isinstance(_build(dialect), AsyncStaticHostListProvider) - class MultiAzClusterPgDialect: - pass # No _WRITER_HOST_QUERY, etc. - provider = _build_host_list_provider( - Properties({"plugins": "failover"}), dd, MultiAzClusterPgDialect()) +def test_aurora_pg_dialect_selects_aurora_provider() -> None: + provider = _build(AuroraPgDialect()) assert type(provider) is AsyncAuroraHostListProvider + assert provider._topology_query == AuroraPgDialect().topology_query + assert provider._default_port == PG_PORT -def test_global_aurora_dialect_selects_global_provider() -> None: - dd = AsyncPsycopgDriverDialect() +def test_aurora_mysql_dialect_selects_aurora_provider_with_mysql_sql() -> None: + # Regression: before the dialect supplied the provider, an Aurora MySQL + # connection got the provider's PostgreSQL-default topology query and + # port 5432, so discovery returned nothing until a later reconfigure. + provider = _build(AuroraMysqlDialect(), AsyncAiomysqlDriverDialect()) + assert type(provider) is AsyncAuroraHostListProvider + assert provider._topology_query == AuroraMysqlDialect().topology_query + assert provider._default_port == MYSQL_PORT - class GlobalAuroraPgDialect: - topology_query = "SELECT aurora_global_db_instance_identifier()" - provider = _build_host_list_provider( - Properties({ - "plugins": "failover", +def test_global_aurora_pg_dialect_selects_global_provider() -> None: + provider = _build( + GlobalAuroraPgDialect(), + props=Properties({ "global_cluster_instance_host_patterns": ( "us-east-1:global-db-us-east-1.cluster-xyz.us-east-1.rds.amazonaws.com"), - }), - dd, - GlobalAuroraPgDialect(), - ) + })) assert isinstance(provider, AsyncGlobalAuroraHostListProvider) + assert provider._topology_query == GlobalAuroraPgDialect().topology_query -def test_plain_aurora_dialect_selects_aurora_provider() -> None: - dd = AsyncPsycopgDriverDialect() +def test_global_aurora_mysql_dialect_selects_global_provider() -> None: + provider = _build(GlobalAuroraMysqlDialect(), AsyncAiomysqlDriverDialect()) + assert isinstance(provider, AsyncGlobalAuroraHostListProvider) + assert provider._topology_query == GlobalAuroraMysqlDialect().topology_query + assert provider._default_port == MYSQL_PORT - class AuroraPgDialect: - pass - provider = _build_host_list_provider( - Properties({"plugins": "failover"}), dd, AuroraPgDialect()) - assert type(provider) is AsyncAuroraHostListProvider +def test_multi_az_pg_dialect_selects_multi_az_provider() -> None: + dialect = MultiAzClusterPgDialect() + provider = _build(dialect) + assert isinstance(provider, AsyncMultiAzHostListProvider) + assert provider._topology_query == dialect.topology_query + assert provider._writer_host_query == dialect.writer_id_query + assert provider._host_id_query == dialect.host_id_query + # The PG writer query returns a single column. + assert provider._writer_host_column_index == 0 + + +def test_multi_az_mysql_dialect_carries_writer_column_index() -> None: + # SHOW REPLICA STATUS carries the writer host at column 39; losing that + # index silently reads the wrong column and no writer is ever found. + dialect = MultiAzClusterMysqlDialect() + provider = _build(dialect, AsyncAiomysqlDriverDialect()) + assert isinstance(provider, AsyncMultiAzHostListProvider) + assert provider._writer_host_column_index == dialect._WRITER_HOST_COLUMN_INDEX + assert provider._default_port == MYSQL_PORT + + +def test_provider_receives_provider_service_for_dialect_confirmation() -> None: + # The provider must be able to see is_dialect_confirmed, otherwise it + # would query topology with SQL from an unconfirmed dialect guess. + service = _FakeProviderService(AsyncPsycopgDriverDialect()) + provider = _build(AuroraPgDialect(), service=service) + assert isinstance(provider, AsyncAuroraHostListProvider) + assert provider._provider_service is service diff --git a/tests/unit/test_aio_plugin_factory.py b/tests/unit/test_aio_plugin_factory.py index 3090bff8c..c2cbd6afa 100644 --- a/tests/unit/test_aio_plugin_factory.py +++ b/tests/unit/test_aio_plugin_factory.py @@ -147,9 +147,9 @@ def test_sort_factories_respects_custom_endpoint_before_failover(): def test_build_async_plugins_returns_empty_when_no_plugins_property(): # build_async_plugins itself applies NO defaults -- the default plugin list - # is injected into props by AsyncAwsWrapperConnection.connect (so - # _build_host_list_provider stays in sync). Given bare props, this returns - # []. See test_connect_applies_default_plugins_when_unset for the + # is injected into props by AsyncAwsWrapperConnection.connect, so every + # reader of the property sees the same list. Given bare props, this + # returns []. See test_connect_applies_default_plugins_when_unset for the # connect-level default behavior. props = Properties({"host": "h"}) plugins = build_async_plugins(_svc(props), props) diff --git a/tests/unit/test_aio_plugin_service_extras.py b/tests/unit/test_aio_plugin_service_extras.py index 3e013a65e..7ab2a752b 100644 --- a/tests/unit/test_aio_plugin_service_extras.py +++ b/tests/unit/test_aio_plugin_service_extras.py @@ -85,6 +85,38 @@ def test_set_availability_covers_all_aliases(): assert svc.get_availability(alias) == HostAvailability.UNAVAILABLE +def test_availability_written_by_alias_is_readable_by_url(): + """set_availability writes alias keys ("host:port"); the topology + re-hydration path reads HostInfo.url ("host:port/"). They have to + resolve to one key, or every mark is silently discarded.""" + svc = _make_service() + host = HostInfo(host="instance-1.abc.us-east-2.rds.amazonaws.com", port=3306) + + svc.set_availability(frozenset(host.as_aliases()), HostAvailability.UNAVAILABLE) + + assert svc.get_availability(host.url) == HostAvailability.UNAVAILABLE + # The re-hydration path is what applies it to freshly built topology. + rebuilt = HostInfo(host=host.host, port=host.port) + svc._update_host_availability((rebuilt,)) + assert rebuilt.get_availability() == HostAvailability.UNAVAILABLE + + +def test_set_availability_marks_the_live_hosts_too(): + """Host selection reads HostInfo.get_availability() directly, so a + cache-only write would not reach it until topology was rebuilt.""" + svc = _make_service() + writer = HostInfo(host="instance-1.abc.us-east-2.rds.amazonaws.com", + port=3306, role=HostRole.WRITER) + reader = HostInfo(host="instance-2.abc.us-east-2.rds.amazonaws.com", + port=3306, role=HostRole.READER) + svc._all_hosts = (writer, reader) + + svc.set_availability(frozenset(writer.as_aliases()), HostAvailability.UNAVAILABLE) + + assert writer.get_availability() == HostAvailability.UNAVAILABLE + assert reader.get_availability() != HostAvailability.UNAVAILABLE + + def test_default_plugin_accepts_random_strategy(): plugin = AsyncDefaultPlugin() assert plugin.accepts_strategy(HostRole.READER, "random") is True @@ -870,18 +902,73 @@ async def _outer_post_connect_probe(): assert order == ["upgrade", "outer-post-connect"] -def test_update_database_dialect_settles_and_skips_non_mysql(): - """update_database_dialect: non-aiomysql drivers settle immediately (no - probe ever); a settled service never re-probes (sync can_update parity).""" +def _async_conn_answering(responses, default=()): + """A connection whose cursor answers each verification query with its own rows. + + Keys are substrings matched against the query being executed. One cursor + serves every rule, so a single return value cannot express + "the global tables exist but there is only one region". + """ + state = {"query": ""} + + def rows(): + for fragment, response in responses.items(): + if fragment in state["query"]: + return list(response) + return list(default) + + async def execute(query, *args, **kwargs): + state["query"] = query + + cursor = MagicMock(name="cursor") + cursor.execute = AsyncMock(side_effect=execute) + cursor.fetchall = AsyncMock(side_effect=lambda: rows()) + cursor.fetchone = AsyncMock(side_effect=lambda: next(iter(rows()), None)) + cursor.close = MagicMock() + + conn = MagicMock(name="conn") + conn.cursor = MagicMock(return_value=cursor) + return conn + + +def _manager_guessing(dialect, dialect_code): + """A dialect manager mid-resolution: it has a guess that live verification + may still replace, which is the state the wrapper hands over.""" + from aws_advanced_python_wrapper.database_dialect import \ + DatabaseDialectManager + + manager = DatabaseDialectManager(Properties()) + manager._dialect = dialect + manager._dialect_code = dialect_code + manager._can_update = True + manager._confirmed = False + return manager + + +def _service_with_manager(dialect, dialect_code, props=None): + props = props if props is not None else Properties() + dd = MagicMock() + dd._driver_name = "aiomysql" + dd.is_in_transaction = AsyncMock(return_value=False) + svc = AsyncPluginServiceImpl(props, dd) + svc.database_dialect = dialect + svc.database_dialect_manager = _manager_guessing(dialect, dialect_code) + return svc + + +def test_update_database_dialect_confirms_when_no_manager(): + """With no manager there is no candidate chain to walk, so the dialect + settles rather than being retried on every connect forever. A confirmed + dialect is never re-verified.""" dd = MagicMock() dd._driver_name = "psycopg" svc = AsyncPluginServiceImpl(Properties(), dd) conn = MagicMock(name="conn") async def _run(): + assert svc.is_dialect_confirmed is False await svc.update_database_dialect(conn) - assert svc._database_dialect_settled is True - # No probe on the connection for non-MySQL drivers. + assert svc.is_dialect_confirmed is True conn.cursor.assert_not_called() # Second call is a fast no-op. await svc.update_database_dialect(conn) @@ -890,6 +977,315 @@ async def _run(): asyncio.run(_run()) +def test_a_failed_candidate_settles_on_the_pattern_guess(): + """A candidate that could not be asked is a candidate that answered no. + + Deliberate trade-off, and a reversal: this used to leave the dialect + unconfirmed so the next connect would retry. But a rule error is + indistinguishable from "this engine has no such object", which on Aurora + is the normal answer for the Multi-AZ rule -- so that reading left every + Aurora cluster endpoint permanently unconfirmed with no topology. Sync + and the reference both swallow the error and settle. The cost is that a + genuinely dead connection now confirms the guess and caches it. + """ + from aws_advanced_python_wrapper.database_dialect import (DialectCode, + RdsMysqlDialect) + + svc = _service_with_manager(RdsMysqlDialect(), DialectCode.RDS_MYSQL) + conn = MagicMock(name="conn") + conn.cursor.side_effect = RuntimeError("connection went away") + + async def _run(): + await svc.update_database_dialect(conn) + assert svc.is_dialect_confirmed is True + assert isinstance(svc.database_dialect, RdsMysqlDialect) + + asyncio.run(_run()) + + +def test_update_database_dialect_rebuilds_provider_on_dialect_change(): + """An Aurora MySQL instance endpoint is guessed as RdsMysqlDialect, whose + supplier is the static provider. Once verification upgrades the dialect the + provider must be REPLACED with the Aurora one -- reconfiguring is not + enough, the provider type itself is dialect specific.""" + from aws_advanced_python_wrapper.aio.host_list_provider import ( + AsyncAuroraHostListProvider, AsyncStaticHostListProvider) + from aws_advanced_python_wrapper.database_dialect import ( + AuroraMysqlDialect, DialectCode, RdsMysqlDialect) + + props = Properties({"host": "instance-1.xyz.us-east-2.rds.amazonaws.com", "port": "3306"}) + svc = _service_with_manager(RdsMysqlDialect(), DialectCode.RDS_MYSQL, props) + svc.host_list_provider = AsyncStaticHostListProvider(props) + + # aurora_version present, but not a global database: Aurora MySQL. + conn = _async_conn_answering({ + "aurora_version": [("aurora_version", "3.0")], + "AURORA_GLOBAL_DB_STATUS": [], + }) + + async def _run(): + await svc.update_database_dialect(conn) + assert isinstance(svc.database_dialect, AuroraMysqlDialect) + assert svc.is_dialect_confirmed is True + provider = svc.host_list_provider + assert isinstance(provider, AsyncAuroraHostListProvider) + # The replacement carries the MySQL topology SQL and port, not the + # provider ctor's PostgreSQL defaults. + assert provider._topology_query == AuroraMysqlDialect().topology_query + assert provider._default_port == 3306 + + asyncio.run(_run()) + + +def test_update_database_dialect_reaches_global_aurora_mysql(): + """The whole candidate chain is walked, not one hardcoded query: an + Aurora MySQL cluster-endpoint guess must upgrade to Global Aurora MySQL + on a multi-region cluster, since that dialect carries the region-aware + topology SQL and the region-filtering host list provider.""" + from aws_advanced_python_wrapper.database_dialect import ( + AuroraMysqlDialect, DialectCode, GlobalAuroraMysqlDialect) + + svc = _service_with_manager(AuroraMysqlDialect(), DialectCode.AURORA_MYSQL) + # Both global views present, and more than one region. + conn = _async_conn_answering({ + "AURORA_GLOBAL_DB_STATUS": [(1,)], + "AURORA_GLOBAL_DB_INSTANCE_STATUS": [(1,)], + "aurora_global_db_status": [(2,)], + }) + + async def _run(): + await svc.update_database_dialect(conn) + assert isinstance(svc.database_dialect, GlobalAuroraMysqlDialect) + assert svc.is_dialect_confirmed is True + + asyncio.run(_run()) + + +def test_update_database_dialect_stops_at_first_matching_candidate(): + """Candidate order decides the answer, and the first match wins. + + RdsMysqlDialect lists AURORA_MYSQL before GLOBAL_AURORA_MYSQL, and the + Aurora rules match a global cluster too -- so an RDS MySQL *instance* + endpoint on a global cluster resolves to AuroraMysqlDialect and the + walk never reaches the global candidate. This pins sync's behaviour + (database_dialect.py:412), not a preference: changing it means + reordering the shared candidate tuple for both engines.""" + from aws_advanced_python_wrapper.database_dialect import ( + AuroraMysqlDialect, DialectCode, GlobalAuroraMysqlDialect, + RdsMysqlDialect) + + svc = _service_with_manager(RdsMysqlDialect(), DialectCode.RDS_MYSQL) + # A genuine multi-region global cluster. + conn = _async_conn_answering({ + "aurora_version": [("aurora_version", "3.0")], + "AURORA_GLOBAL_DB_STATUS": [(1,)], + "AURORA_GLOBAL_DB_INSTANCE_STATUS": [(1,)], + "aurora_global_db_status": [(2,)], + }) + + async def _run(): + await svc.update_database_dialect(conn) + assert isinstance(svc.database_dialect, AuroraMysqlDialect) + assert not isinstance(svc.database_dialect, GlobalAuroraMysqlDialect) + + asyncio.run(_run()) + + +def test_update_database_dialect_single_region_stops_at_aurora(): + """A single-region Aurora cluster has the global views but only one + region, so it must NOT be taken for a global database.""" + from aws_advanced_python_wrapper.database_dialect import ( + AuroraMysqlDialect, DialectCode, GlobalAuroraMysqlDialect, + RdsMysqlDialect) + + svc = _service_with_manager(RdsMysqlDialect(), DialectCode.RDS_MYSQL) + conn = _async_conn_answering({ + "aurora_version": [("aurora_version", "3.0")], + "AURORA_GLOBAL_DB_STATUS": [(1,)], + "AURORA_GLOBAL_DB_INSTANCE_STATUS": [(1,)], + "aurora_global_db_status": [(1,)], + }) + + async def _run(): + await svc.update_database_dialect(conn) + assert isinstance(svc.database_dialect, AuroraMysqlDialect) + assert not isinstance(svc.database_dialect, GlobalAuroraMysqlDialect) + + asyncio.run(_run()) + + +def test_update_database_dialect_verifies_pg_candidates(): + """PG is verified too, not just MySQL: an Aurora PG instance endpoint is + guessed as RdsPgDialect, whose supplier has no topology query at all.""" + from aws_advanced_python_wrapper.database_dialect import (AuroraPgDialect, + DialectCode, + RdsPgDialect) + + svc = _service_with_manager(RdsPgDialect(), DialectCode.RDS_PG) + svc.driver_dialect._driver_name = "psycopg" + conn = _async_conn_answering({ + "pg_proc": [(1,)], + "aurora_stat_utils": [(True,)], + "aurora_replica_status": [(1,)], + "aurora_global_db_status": [], + }) + + async def _run(): + await svc.update_database_dialect(conn) + assert isinstance(svc.database_dialect, AuroraPgDialect) + assert svc.is_dialect_confirmed is True + + asyncio.run(_run()) + + +def test_update_database_dialect_verification_timeout_leaves_unconfirmed(): + """Verification that hangs must not hang the connect. The timeout comes from + AUXILIARY_QUERY_TIMEOUT_SEC, and the resulting QueryTimeoutError leaves + the dialect unconfirmed for the next connect to retry.""" + from aws_advanced_python_wrapper.database_dialect import (DialectCode, + RdsMysqlDialect) + + props = Properties({"auxiliary_query_timeout_sec": "0.05"}) + svc = _service_with_manager(RdsMysqlDialect(), DialectCode.RDS_MYSQL, props) + + async def _never_returns(*args, **kwargs): + await asyncio.sleep(30) + + cursor = MagicMock(name="cursor") + cursor.execute = AsyncMock(side_effect=_never_returns) + cursor.close = MagicMock() + conn = MagicMock(name="conn") + conn.cursor = MagicMock(return_value=cursor) + + async def _run(): + await svc.update_database_dialect(conn) + assert svc.is_dialect_confirmed is False + assert isinstance(svc.database_dialect, RdsMysqlDialect) + + asyncio.run(_run()) + + +def test_provider_identity_covers_every_dialect_supplied_argument(): + """Keeps _PROVIDER_DIALECT_ATTRS honest. + + Provider equivalence is decided by comparing a fixed list of + attributes. If a supplier in aio/host_list_provider gains a new + dialect-derived argument and the list is not extended, two providers + differing only in that argument would compare equal and the stale one + would be kept -- a wrong-SQL bug that no other test would catch. This + asserts the list names every keyword-only argument the suppliers take. + """ + import inspect + + from aws_advanced_python_wrapper.aio import host_list_provider as hlp + + suppliers = (hlp.create_static_provider, hlp.create_aurora_provider, + hlp.create_global_aurora_provider, hlp.create_multi_az_provider) + dialect_supplied = { + name + for supplier in suppliers + for name, param in inspect.signature(supplier).parameters.items() + if param.kind is inspect.Parameter.KEYWORD_ONLY + } + covered = {attr.lstrip("_") + for attr in AsyncPluginServiceImpl._PROVIDER_DIALECT_ATTRS} + missing = dialect_supplied - covered + assert not missing, ( + f"supplier argument(s) {sorted(missing)} are not in " + f"_PROVIDER_DIALECT_ATTRS, so providers differing only in them " + f"would be treated as interchangeable") + + +def test_host_list_provider_is_kept_when_the_dialect_supplies_the_same_one(): + """A dialect change is not a provider change. + + Several dialects supply the same no-topology provider. Replacing it + anyway tears down the topology monitor and its connection and discards + the cached topology, so an equivalent provider must be kept. + """ + from aws_advanced_python_wrapper.aio.host_list_provider import \ + AsyncStaticHostListProvider + from aws_advanced_python_wrapper.database_dialect import ( + MysqlDatabaseDialect, RdsMysqlDialect) + + props = Properties({"host": "h", "port": "3306"}) + svc = _service_with_manager(RdsMysqlDialect(), None, props) + existing = AsyncStaticHostListProvider(props) + existing.stop = AsyncMock() + svc.host_list_provider = existing + + # Both dialects supply the static provider, so the upgrade must not + # swap the instance. + svc.database_dialect = MysqlDatabaseDialect() + + async def _run(): + await svc._update_host_list_provider(MagicMock(name="conn")) + assert svc.host_list_provider is existing + existing.stop.assert_not_awaited() + + asyncio.run(_run()) + + +def test_host_list_provider_is_replaced_when_the_topology_sql_differs(): + """The same provider *class* is not enough: Aurora MySQL and Aurora PG + both supply AsyncAuroraHostListProvider but with different topology SQL + and ports, so the instance must still be replaced.""" + from aws_advanced_python_wrapper.database_dialect import ( + AuroraMysqlDialect, AuroraPgDialect) + + props = Properties({"host": "h", "port": "5432"}) + svc = _service_with_manager(AuroraPgDialect(), None, props) + existing = AuroraPgDialect().get_async_host_list_provider_supplier()(svc, props) + existing.stop = AsyncMock() + svc.host_list_provider = existing + svc.force_refresh_host_list = AsyncMock() + + svc.database_dialect = AuroraMysqlDialect() + + async def _run(): + await svc._update_host_list_provider(MagicMock(name="conn")) + provider = svc.host_list_provider + assert provider is not existing + assert type(provider) is type(existing) + assert provider._topology_query == AuroraMysqlDialect().topology_query + assert provider._default_port == 3306 + existing.stop.assert_awaited_once() + + asyncio.run(_run()) + + +def test_replacement_provider_inherits_the_panic_mode_probe(): + """The wrapper wires set_probe_host once, onto the provider built before + the connect. A replacement that does not inherit it cannot discover the + new writer when its monitoring connection dies mid-failover -- which is + exactly the case on the connections that needed a dialect upgrade.""" + from aws_advanced_python_wrapper.database_dialect import ( + AuroraMysqlDialect, AuroraPgDialect, RdsMysqlDialect) + + props = Properties({"host": "instance-1.xyz.us-east-2.rds.amazonaws.com", "port": "3306"}) + svc = _service_with_manager(RdsMysqlDialect(), None, props) + existing = AuroraMysqlDialect().get_async_host_list_provider_supplier()(svc, props) + existing.stop = AsyncMock() + + async def _probe(host_info): + return (MagicMock(name="conn"), HostRole.WRITER) + + existing.set_probe_host(_probe) + svc.host_list_provider = existing + svc.force_refresh_host_list = AsyncMock() + + # A dialect whose provider differs, so a replacement really happens. + svc.database_dialect = AuroraPgDialect() + + async def _run(): + await svc._update_host_list_provider(MagicMock(name="conn")) + replacement = svc.host_list_provider + assert replacement is not existing + assert replacement._probe_host is _probe + + asyncio.run(_run()) + + def test_is_static_host_list_provider_reflects_provider_type(): from aws_advanced_python_wrapper.aio.host_list_provider import \ AsyncStaticHostListProvider @@ -901,3 +1297,160 @@ def test_is_static_host_list_provider_reflects_provider_type(): assert svc.is_static_host_list_provider() is True svc.host_list_provider = MagicMock() # any dynamic provider assert svc.is_static_host_list_provider() is False + + +def _conn_raising_for(fragments, answers=None): + """A connection whose cursor RAISES for queries matching any fragment. + + Models the real shape: on Aurora the Multi-AZ rule asks about rds_tools / + mysql.rds_topology, which do not exist, so the server errors rather than + returning no rows. + """ + answers = answers or {} + state = {"query": ""} + + async def execute(query, *args, **kwargs): + state["query"] = query + for frag in fragments: + if frag in query: + raise RuntimeError(f"relation does not exist: {frag}") + + def rows(): + for frag, response in answers.items(): + if frag in state["query"]: + return list(response) + return [] + + cursor = MagicMock(name="cursor") + cursor.execute = AsyncMock(side_effect=execute) + cursor.fetchall = AsyncMock(side_effect=lambda: rows()) + cursor.fetchone = AsyncMock(side_effect=lambda: next(iter(rows()), None)) + cursor.close = MagicMock() + conn = MagicMock(name="conn") + conn.cursor = MagicMock(return_value=cursor) + return conn + + +def test_aurora_cluster_endpoint_confirms_despite_an_erroring_candidate(): + """An erroring candidate is a candidate that answered no, not a fatal + condition. On a plain Aurora cluster the Multi-AZ rule ALWAYS errors + (no rds_tools / mysql.rds_topology), and the global rule answers no, so + escalating left every Aurora cluster endpoint with an unconfirmed dialect, + a one-host topology and no monitor -- silently, and permanently.""" + from aws_advanced_python_wrapper.database_dialect import (AuroraPgDialect, + DialectCode) + + props = Properties({"host": "db.cluster-xyz.us-east-1.rds.amazonaws.com", "port": "5432"}) + svc = _service_with_manager(AuroraPgDialect(), DialectCode.AURORA_PG, props) + svc.driver_dialect._driver_name = "psycopg" + conn = _conn_raising_for( + ["rds_tools", "multi_az"], + {"pg_proc": [(1,)], "aurora_stat_utils": [(True,)], + "aurora_replica_status": [(1,)], + "AURORA_GLOBAL_DB_STATUS": [], "aurora_global_db_status": []}) + + async def _run(): + await svc.update_database_dialect(conn) + assert svc.is_dialect_confirmed is True, ( + "an erroring candidate must not leave the dialect unconfirmed") + assert isinstance(svc.database_dialect, AuroraPgDialect) + + asyncio.run(_run()) + + +def test_verification_timeout_is_actually_bounded(caplog): + """The post-state alone cannot tell a real timeout from a slow walk that + eventually gave up: with wait_for removed this test still passed, taking + 90 seconds. Assert the timeout's own message reaches the log.""" + import logging + + from aws_advanced_python_wrapper.database_dialect import (DialectCode, + RdsMysqlDialect) + from aws_advanced_python_wrapper.utils.messages import Messages + + props = Properties({"auxiliary_query_timeout_sec": "0.05"}) + svc = _service_with_manager(RdsMysqlDialect(), DialectCode.RDS_MYSQL, props) + + async def _never_returns(*args, **kwargs): + await asyncio.sleep(30) + + cursor = MagicMock(name="cursor") + cursor.execute = AsyncMock(side_effect=_never_returns) + cursor.close = MagicMock() + conn = MagicMock(name="conn") + conn.cursor = MagicMock(return_value=cursor) + + async def _run(): + with caplog.at_level( + logging.DEBUG, + logger="aws_advanced_python_wrapper.aio.plugin_service"): + await svc.update_database_dialect(conn) + assert svc.is_dialect_confirmed is False + timeout_text = Messages.get("DatabaseDialectManager.QueryForDialectTimeout") + assert timeout_text in caplog.text, ( + "the timeout message never reached the log, so wait_for may not " + f"have fired; got: {caplog.text[:400]}") + + asyncio.run(_run()) + + +def test_unconfirmed_dialect_reports_the_seed_host_without_querying(): + """The gating this branch exists to add: until the dialect is verified a + provider must report the connection-string host and must NOT run topology + SQL, because the SQL it would run belongs to the unverified guess.""" + from aws_advanced_python_wrapper.aio.host_list_provider import \ + AsyncAuroraHostListProvider + + props = Properties({"host": "db.cluster-xyz.us-east-1.rds.amazonaws.com", "port": "5432"}) + service = MagicMock() + service.is_dialect_confirmed = False + service.driver_dialect = MagicMock() + + provider = AsyncAuroraHostListProvider( + props, driver_dialect=MagicMock(), + topology_query="SELECT should_never_run", default_port=5432) + provider._provider_service = service + + cursor = MagicMock(name="cursor") + cursor.execute = AsyncMock(return_value=None) + cursor.fetchall = AsyncMock(return_value=[]) + cursor.fetchone = AsyncMock(return_value=None) + cursor.close = MagicMock() + conn = MagicMock(name="conn") + conn.cursor = MagicMock(return_value=cursor) + + async def _run(): + assert await provider.refresh(conn) == provider._initial_hosts + assert await provider.force_refresh(conn) == provider._initial_hosts + # The gate is the point: no SQL ran and no monitor was built. + cursor.execute.assert_not_awaited() + assert provider._monitor is None + + # And the gate tracks the service flag rather than latching. The + # open direction is asserted on the predicate, not on downstream + # SQL: that path runs through the topology monitor, which this + # provider deliberately has no connection factory for. + assert provider._is_dialect_confirmed() is False + service.is_dialect_confirmed = True + assert provider._is_dialect_confirmed() is True + + asyncio.run(_run()) + + +def test_an_expired_availability_mark_stops_excluding_the_host(): + """set_availability also marks the live HostInfo, and the static provider + and the pre-confirmation seed host reuse one object forever -- so without + a decay path an UNAVAILABLE mark would exclude that host from selection + for the process lifetime.""" + svc = _make_service() + host = HostInfo(host="instance-1.abc.us-east-2.rds.amazonaws.com", port=3306) + svc._all_hosts = (host,) + + svc.set_availability(frozenset(host.as_aliases()), HostAvailability.UNAVAILABLE) + assert host.get_availability() == HostAvailability.UNAVAILABLE + + # Expire the cache entry the mark was mirrored from. + AsyncPluginServiceImpl._host_availability_expiring_cache.clear() + svc._update_host_availability((host,)) + + assert host.get_availability() == HostAvailability.AVAILABLE diff --git a/tests/unit/test_aio_wrapper.py b/tests/unit/test_aio_wrapper.py index c27f4b2b9..ac16601fc 100644 --- a/tests/unit/test_aio_wrapper.py +++ b/tests/unit/test_aio_wrapper.py @@ -121,6 +121,15 @@ def _cursor(*args: Any, **kwargs: Any) -> MagicMock: return conn +# Naming the dialect makes it confirmed at resolution time, so connect +# never runs the dialect-identification probe. These tests measure cursor +# and connection routing against a single shared mock cursor, and a probe +# would open cursors and consume scripted results that the assertions +# count. Dialect probing has its own tests (test_dialect.py, +# test_aio_plugin_service_extras.py). +_NAMED_DIALECT = {"wrapper_dialect": "pg"} + + def _make_mock_async_cursor() -> MagicMock: cur = MagicMock() cur.execute = AsyncMock(return_value=None) @@ -162,7 +171,7 @@ def test_connect_opens_via_plugin_pipeline_and_returns_wrapper(): async def _body() -> AsyncAwsWrapperConnection: return await AsyncAwsWrapperConnection.connect( target=_build_mock_psycopg_connect(raw_conn), - conninfo="host=example.com user=u password=p dbname=d port=5432", + conninfo="host=example.com user=u password=p dbname=d port=5432", **_NAMED_DIALECT, plugins=[plugin], ) @@ -186,7 +195,7 @@ async def _target(**kwargs: Any) -> Any: async def _body() -> None: await AsyncAwsWrapperConnection.connect( target=_target, - conninfo="host=h.example user=u password=p dbname=db port=6543", + conninfo="host=h.example user=u password=p dbname=db port=6543", **_NAMED_DIALECT, ) asyncio.run(_body()) @@ -204,7 +213,7 @@ def test_cursor_is_sync_and_returns_async_cursor_wrapper(): async def _body() -> AsyncAwsWrapperConnection: return await AsyncAwsWrapperConnection.connect( target=_build_mock_psycopg_connect(raw_conn), - conninfo="host=h user=u password=p dbname=d port=5432", + conninfo="host=h user=u password=p dbname=d port=5432", **_NAMED_DIALECT, ) wrapper = asyncio.run(_body()) @@ -221,7 +230,7 @@ def test_cursor_execute_routes_through_plugin_pipeline(): async def _body() -> None: wrapper = await AsyncAwsWrapperConnection.connect( target=_build_mock_psycopg_connect(raw_conn), - conninfo="host=h user=u password=p dbname=d port=5432", + conninfo="host=h user=u password=p dbname=d port=5432", **_NAMED_DIALECT, plugins=[plugin], ) cur = wrapper.cursor() @@ -259,7 +268,7 @@ def test_cursor_creation_is_lazy_and_pipelined(): async def _body() -> None: wrapper = await AsyncAwsWrapperConnection.connect( target=_build_mock_psycopg_connect(raw_conn), - conninfo="host=h user=u password=p dbname=d port=5432", + conninfo="host=h user=u password=p dbname=d port=5432", **_NAMED_DIALECT, plugins=[plugin], ) calls_after_connect = raw_conn.cursor.call_count @@ -324,7 +333,7 @@ def init_host_provider(self, props, host_list_provider_service, async def _body() -> AsyncAwsWrapperConnection: return await AsyncAwsWrapperConnection.connect( target=_build_mock_psycopg_connect(raw_conn), - conninfo="host=h user=u password=p dbname=d port=5432", + conninfo="host=h user=u password=p dbname=d port=5432", **_NAMED_DIALECT, plugins=[ProbePlugin([])], ) @@ -337,7 +346,7 @@ def _connected_wrapper(raw_conn: Any) -> AsyncAwsWrapperConnection: async def _body() -> AsyncAwsWrapperConnection: return await AsyncAwsWrapperConnection.connect( target=_build_mock_psycopg_connect(raw_conn), - conninfo="host=h user=u password=p dbname=d port=5432", + conninfo="host=h user=u password=p dbname=d port=5432", **_NAMED_DIALECT, ) return asyncio.run(_body()) @@ -566,7 +575,7 @@ def test_connection_commit_rollback_close_route_through_pipeline(): async def _body() -> None: wrapper = await AsyncAwsWrapperConnection.connect( target=_build_mock_psycopg_connect(raw_conn), - conninfo="host=h user=u password=p dbname=d port=5432", + conninfo="host=h user=u password=p dbname=d port=5432", **_NAMED_DIALECT, plugins=[plugin], ) await wrapper.commit() @@ -598,7 +607,7 @@ async def _body() -> None: # context-manager close path from default-plugin activity. async with await AsyncAwsWrapperConnection.connect( target=_build_mock_psycopg_connect(raw_conn), - conninfo="host=h user=u password=p dbname=d port=5432", + conninfo="host=h user=u password=p dbname=d port=5432", **_NAMED_DIALECT, plugins="", ) as conn: assert isinstance(conn, AsyncAwsWrapperConnection) @@ -616,7 +625,7 @@ def test_connect_applies_default_plugins_when_unset(): async def _body() -> AsyncAwsWrapperConnection: return await AsyncAwsWrapperConnection.connect( target=_build_mock_psycopg_connect(raw_conn), - conninfo="host=h user=u password=p dbname=d port=5432", + conninfo="host=h user=u password=p dbname=d port=5432", **_NAMED_DIALECT, ) conn = asyncio.run(_body()) @@ -632,7 +641,7 @@ def test_connect_explicit_blank_plugins_loads_none(): async def _body() -> AsyncAwsWrapperConnection: return await AsyncAwsWrapperConnection.connect( target=_build_mock_psycopg_connect(raw_conn), - conninfo="host=h user=u password=p dbname=d port=5432", + conninfo="host=h user=u password=p dbname=d port=5432", **_NAMED_DIALECT, plugins="", ) @@ -657,7 +666,7 @@ def test_connect_rolls_back_lingering_nonautocommit_transaction(): async def _body() -> None: await AsyncAwsWrapperConnection.connect( target=_build_mock_psycopg_connect(raw_conn), - conninfo="host=h user=u password=p dbname=d port=5432", + conninfo="host=h user=u password=p dbname=d port=5432", **_NAMED_DIALECT, plugins="", ) @@ -673,7 +682,7 @@ def test_connect_does_not_roll_back_autocommit_connection(): async def _body() -> None: await AsyncAwsWrapperConnection.connect( target=_build_mock_psycopg_connect(raw_conn), - conninfo="host=h user=u password=p dbname=d port=5432", + conninfo="host=h user=u password=p dbname=d port=5432", **_NAMED_DIALECT, plugins="", ) @@ -688,7 +697,7 @@ def test_connection_getattr_forwards_to_raw_conn(): async def _body() -> AsyncAwsWrapperConnection: return await AsyncAwsWrapperConnection.connect( target=_build_mock_psycopg_connect(raw_conn), - conninfo="host=h user=u password=p dbname=d port=5432", + conninfo="host=h user=u password=p dbname=d port=5432", **_NAMED_DIALECT, ) wrapper = asyncio.run(_body()) @@ -701,7 +710,7 @@ def test_cursor_getattr_forwards_to_target_cursor(): async def _body() -> AsyncAwsWrapperCursor: wrapper = await AsyncAwsWrapperConnection.connect( target=_build_mock_psycopg_connect(raw_conn), - conninfo="host=h user=u password=p dbname=d port=5432", + conninfo="host=h user=u password=p dbname=d port=5432", **_NAMED_DIALECT, ) cur = wrapper.cursor() # Before first use the driver cursor does not exist yet: the @@ -726,7 +735,7 @@ def test_cursor_async_context_manager_closes_on_exit(): async def _body() -> None: wrapper = await AsyncAwsWrapperConnection.connect( target=_build_mock_psycopg_connect(raw_conn), - conninfo="host=h user=u password=p dbname=d port=5432", + conninfo="host=h user=u password=p dbname=d port=5432", **_NAMED_DIALECT, ) async with wrapper.cursor() as cur: assert isinstance(cur, AsyncAwsWrapperCursor) @@ -847,7 +856,7 @@ def _make_wrapper_and_cursor() -> tuple: async def _body() -> AsyncAwsWrapperConnection: return await AsyncAwsWrapperConnection.connect( target=_build_mock_psycopg_connect(raw_conn), - conninfo="host=h user=u password=p dbname=d port=5432", + conninfo="host=h user=u password=p dbname=d port=5432", **_NAMED_DIALECT, ) wrapper = asyncio.run(_body()) @@ -944,7 +953,7 @@ def test_connection_autocommit_getter_reads_target_connection_directly(): async def _body() -> AsyncAwsWrapperConnection: return await AsyncAwsWrapperConnection.connect( target=_build_mock_psycopg_connect(raw_conn), - conninfo="host=h user=u password=p dbname=d port=5432", + conninfo="host=h user=u password=p dbname=d port=5432", **_NAMED_DIALECT, ) wrapper = asyncio.run(_body()) @@ -960,7 +969,7 @@ def test_connection_set_autocommit_awaits_driver_dialect(): async def _body() -> AsyncAwsWrapperConnection: return await AsyncAwsWrapperConnection.connect( target=_build_mock_psycopg_connect(raw_conn), - conninfo="host=h user=u password=p dbname=d port=5432", + conninfo="host=h user=u password=p dbname=d port=5432", **_NAMED_DIALECT, ) wrapper = asyncio.run(_body()) @@ -985,7 +994,7 @@ def test_connection_isolation_level_roundtrip(): async def _body() -> AsyncAwsWrapperConnection: return await AsyncAwsWrapperConnection.connect( target=_build_mock_psycopg_connect(raw_conn), - conninfo="host=h user=u password=p dbname=d port=5432", + conninfo="host=h user=u password=p dbname=d port=5432", **_NAMED_DIALECT, ) wrapper = asyncio.run(_body()) @@ -1017,7 +1026,7 @@ async def _set_async() -> None: async def _body2() -> AsyncAwsWrapperConnection: return await AsyncAwsWrapperConnection.connect( target=_build_mock_psycopg_connect(raw_conn2), - conninfo="host=h user=u password=p dbname=d port=5432", + conninfo="host=h user=u password=p dbname=d port=5432", **_NAMED_DIALECT, ) wrapper2 = asyncio.run(_body2()) @@ -1086,7 +1095,7 @@ def test_tpc_methods_route_through_plugin_pipeline(): async def _body() -> Any: wrapper = await AsyncAwsWrapperConnection.connect( target=_build_mock_psycopg_connect(raw_conn), - conninfo="host=h user=u password=p dbname=d port=5432", + conninfo="host=h user=u password=p dbname=d port=5432", **_NAMED_DIALECT, plugins=[plugin], ) await wrapper.tpc_begin("xid-1") @@ -1140,7 +1149,7 @@ def test_cursor_async_for_iterates_via_plugin_routed_fetchone(): async def _body() -> List[Any]: wrapper = await AsyncAwsWrapperConnection.connect( target=_build_mock_psycopg_connect(raw_conn), - conninfo="host=h user=u password=p dbname=d port=5432", + conninfo="host=h user=u password=p dbname=d port=5432", **_NAMED_DIALECT, plugins=[plugin], ) rows: List[Any] = [] @@ -1167,7 +1176,7 @@ def test_get_read_only_routes_through_plugin_pipeline(): async def _body() -> bool: wrapper = await AsyncAwsWrapperConnection.connect( target=_build_mock_psycopg_connect(raw_conn), - conninfo="host=h user=u password=p dbname=d port=5432", + conninfo="host=h user=u password=p dbname=d port=5432", **_NAMED_DIALECT, plugins=[plugin], ) return await wrapper.get_read_only() @@ -1187,7 +1196,7 @@ def test_get_autocommit_routes_through_plugin_pipeline(): async def _body() -> bool: wrapper = await AsyncAwsWrapperConnection.connect( target=_build_mock_psycopg_connect(raw_conn), - conninfo="host=h user=u password=p dbname=d port=5432", + conninfo="host=h user=u password=p dbname=d port=5432", **_NAMED_DIALECT, plugins=[plugin], ) return await wrapper.get_autocommit() @@ -1261,7 +1270,7 @@ def _factory(props: Any) -> _RecordingTelemetryFactory: async def _body() -> None: await AsyncAwsWrapperConnection.connect( target=_build_mock_psycopg_connect(raw_conn), - conninfo="host=h user=u password=p dbname=d port=5432", + conninfo="host=h user=u password=p dbname=d port=5432", **_NAMED_DIALECT, plugins="", ) @@ -1295,7 +1304,7 @@ async def _boom_target(**kwargs: Any) -> Any: async def _body() -> None: await AsyncAwsWrapperConnection.connect( target=_boom_target, - conninfo="host=h user=u password=p dbname=d port=5432", + conninfo="host=h user=u password=p dbname=d port=5432", **_NAMED_DIALECT, plugins="", ) @@ -1318,7 +1327,7 @@ def test_close_releases_plugin_service_resources(): async def _body() -> None: wrapper = await AsyncAwsWrapperConnection.connect( target=_build_mock_psycopg_connect(raw_conn), - conninfo="host=h user=u password=p dbname=d port=5432", + conninfo="host=h user=u password=p dbname=d port=5432", **_NAMED_DIALECT, plugins="", ) wrapper._plugin_service.release_resources = release # type: ignore[method-assign] @@ -1338,7 +1347,7 @@ def test_aexit_releases_plugin_service_resources(): async def _body() -> None: async with await AsyncAwsWrapperConnection.connect( target=_build_mock_psycopg_connect(raw_conn), - conninfo="host=h user=u password=p dbname=d port=5432", + conninfo="host=h user=u password=p dbname=d port=5432", **_NAMED_DIALECT, plugins="", ) as conn: conn._plugin_service.release_resources = release # type: ignore[method-assign] @@ -1356,7 +1365,7 @@ def test_close_swallows_release_resources_errors(): async def _body() -> None: wrapper = await AsyncAwsWrapperConnection.connect( target=_build_mock_psycopg_connect(raw_conn), - conninfo="host=h user=u password=p dbname=d port=5432", + conninfo="host=h user=u password=p dbname=d port=5432", **_NAMED_DIALECT, plugins="", ) wrapper._plugin_service.release_resources = AsyncMock( # type: ignore[method-assign] diff --git a/tests/unit/test_dialect.py b/tests/unit/test_dialect.py index 5fdc7a1c1..178b98611 100644 --- a/tests/unit/test_dialect.py +++ b/tests/unit/test_dialect.py @@ -108,34 +108,60 @@ def test_pg_is_dialect(mock_conn, mock_cursor, mock_session, pg_dialect, mock_dr assert not pg_dialect.is_dialect(mock_conn, mock_driver_dialect) +def wire_cursor_by_query(mock_cursor, responses, default=()): + """Answer each verification query with its own rows. + + A dialect's rules run several queries through one cursor, so a single + fetchone/fetchall return value cannot express "the extensions query + says yes but the topology query says no". Keys are substrings matched + against the query last executed. + """ + state = {"query": ""} + + def execute(query, *args, **kwargs): + state["query"] = query + + def rows(): + for fragment, response in responses.items(): + if fragment in state["query"]: + return list(response) + return list(default) + + mock_cursor.execute.side_effect = execute + mock_cursor.fetchall.side_effect = rows + mock_cursor.fetchone.side_effect = lambda: next(iter(rows()), None) + + def test_mysql_is_dialect(mock_conn, mock_cursor, mock_session, mysql_dialect, mock_driver_dialect): - records = [("some_value", "some_value"), ("some_value", "mysql")] - mock_cursor.__iter__.return_value = records + # The identifying row need not be first in the result set. + mock_cursor.fetchall.return_value = [("some_value", "some_value"), ("some_value", "mysql")] assert mysql_dialect.is_dialect(mock_conn, mock_driver_dialect) - records = [("some_value", "some_value"), ("some_value", "some_value")] - mock_cursor.__iter__.return_value = records + mock_cursor.fetchall.return_value = [("some_value", "some_value"), ("some_value", "some_value")] assert not mysql_dialect.is_dialect(mock_conn, mock_driver_dialect) -@patch('aws_advanced_python_wrapper.database_dialect.super') -def test_rds_mysql_is_dialect(mock_super, mock_cursor, mock_conn, rds_mysql_dialect, mock_driver_dialect): - mock_super().is_dialect.return_value = True - - records = ("some_value", "source distribution") - mock_cursor.fetchone.return_value = records - +def test_rds_mysql_is_dialect(mock_cursor, mock_conn, rds_mysql_dialect, mock_driver_dialect): + wire_cursor_by_query(mock_cursor, { + "version_comment": [("some_value", "source distribution")], + "report_host": [("report_host", "instance-1")], + }) assert rds_mysql_dialect.is_dialect(mock_conn, mock_driver_dialect) - records = ("some_value", "some_value") - mock_cursor.fetchone.return_value = records - + # Not an RDS build. + wire_cursor_by_query(mock_cursor, { + "version_comment": [("some_value", "some_value")], + "report_host": [("report_host", "instance-1")], + }) assert not rds_mysql_dialect.is_dialect(mock_conn, mock_driver_dialect) - mock_super().is_dialect.return_value = False - + # RDS build, but report_host unset. + wire_cursor_by_query(mock_cursor, { + "version_comment": [("some_value", "source distribution")], + "report_host": [("report_host", "")], + }) assert not rds_mysql_dialect.is_dialect(mock_conn, mock_driver_dialect) @@ -150,47 +176,124 @@ def test_aurora_mysql_is_dialect(mock_conn, mock_cursor, mock_driver_dialect): assert dialect.is_dialect(mock_conn, mock_driver_dialect) is True -@patch('aws_advanced_python_wrapper.database_dialect.super') -def test_aurora_pg_is_dialect(mock_super, mock_conn, mock_cursor, mock_driver_dialect): +def test_aurora_pg_is_dialect(mock_conn, mock_cursor, mock_driver_dialect): aurora_pg_dialect = AuroraPgDialect() mock_conn.cursor.return_value = mock_cursor - mock_super().is_dialect.return_value = True - records = [("aurora_stat_utils", "aurora_stat_utils"), ("some_value", "source distribution")] - mock_cursor.__iter__.return_value = records + pg_base_present = {"pg_proc": [(1,)]} + wire_cursor_by_query(mock_cursor, { + **pg_base_present, + "aurora_stat_utils": [(True,)], + "aurora_replica_status": [(1,)], + }) assert aurora_pg_dialect.is_dialect(mock_conn, mock_driver_dialect) - mock_cursor.fetchone.return_value = None - + # Extension present, topology function absent: not Aurora PG. + wire_cursor_by_query(mock_cursor, { + **pg_base_present, + "aurora_stat_utils": [(True,)], + "aurora_replica_status": [], + }) assert not aurora_pg_dialect.is_dialect(mock_conn, mock_driver_dialect) - mock_cursor.fetchone.return_value = records - mock_super().is_dialect.return_value = False - + # Topology function present, extension absent. + wire_cursor_by_query(mock_cursor, { + **pg_base_present, + "aurora_stat_utils": [(False,)], + "aurora_replica_status": [(1,)], + }) assert not aurora_pg_dialect.is_dialect(mock_conn, mock_driver_dialect) + # The inherited PG base rules fail, so nothing else matters. Replaces + # the previous patch of ``database_dialect.super``: the parent rules are + # now a chained spec rather than a super() call. + wire_cursor_by_query(mock_cursor, { + "pg_proc": [], + "aurora_stat_utils": [(True,)], + "aurora_replica_status": [(1,)], + }) + assert not aurora_pg_dialect.is_dialect(mock_conn, mock_driver_dialect) -@patch('aws_advanced_python_wrapper.database_dialect.super') -def test_rds_pg_is_dialect(mock_super, mock_cursor, mock_conn, rds_pg_dialect, mock_driver_dialect): - mock_super().is_dialect.return_value = True - - mock_cursor.__iter__.return_value = [(False, False), (True, False)] +def test_rds_pg_is_dialect(mock_cursor, mock_conn, rds_pg_dialect, mock_driver_dialect): + # rds_tools without aurora_stat_utils is plain RDS PG; the identifying + # row need not be first. + wire_cursor_by_query(mock_cursor, { + "pg_proc": [(1,)], + "rds.extensions": [(False, False), (True, False)], + }) assert rds_pg_dialect.is_dialect(mock_conn, mock_driver_dialect) - mock_cursor.__iter__.return_value = [(False, False), (False, False)] + wire_cursor_by_query(mock_cursor, { + "pg_proc": [(1,)], + "rds.extensions": [(False, False), (False, False)], + }) + assert not rds_pg_dialect.is_dialect(mock_conn, mock_driver_dialect) + wire_cursor_by_query(mock_cursor, { + "pg_proc": [(1,)], + "rds.extensions": [], + }) assert not rds_pg_dialect.is_dialect(mock_conn, mock_driver_dialect) - mock_cursor.__iter__.return_value = [] + # Both extensions present means Aurora PG, not RDS PG. + wire_cursor_by_query(mock_cursor, { + "pg_proc": [(1,)], + "rds.extensions": [(True, True)], + }) + assert not rds_pg_dialect.is_dialect(mock_conn, mock_driver_dialect) + # Inherited PG base rules fail. + wire_cursor_by_query(mock_cursor, { + "pg_proc": [], + "rds.extensions": [(True, False)], + }) assert not rds_pg_dialect.is_dialect(mock_conn, mock_driver_dialect) - mock_cursor.fetchone.return_value = [(False, False), (True, False)] - mock_super().is_dialect.return_value = False - assert not rds_pg_dialect.is_dialect(mock_conn, mock_driver_dialect) +def test_multi_az_mysql_is_dialect(mock_conn, mock_cursor, mock_driver_dialect): + dialect = MultiAzClusterMysqlDialect() + + # Topology rows present and report_host set: a Multi-AZ MySQL cluster. + mock_cursor.fetchall.return_value = [("instance-1",), ("instance-2",)] + mock_cursor.fetchone.return_value = ("report_host", "instance-1") + assert dialect.is_dialect(mock_conn, mock_driver_dialect) is True + + # No topology rows: not a Multi-AZ cluster. + mock_cursor.fetchall.return_value = [] + assert dialect.is_dialect(mock_conn, mock_driver_dialect) is False + + # Topology present but report_host blank -- the variable exists and is + # unset, which is not the same as a Multi-AZ cluster. + mock_cursor.fetchall.return_value = [("instance-1",)] + mock_cursor.fetchone.return_value = ("report_host", "") + assert dialect.is_dialect(mock_conn, mock_driver_dialect) is False + + # report_host row missing entirely. + mock_cursor.fetchone.return_value = None + assert dialect.is_dialect(mock_conn, mock_driver_dialect) is False + + +def test_multi_az_pg_is_dialect(mock_conn, mock_cursor, mock_driver_dialect): + dialect = MultiAzClusterPgDialect() + + mock_cursor.fetchone.return_value = ("instance-1",) + assert dialect.is_dialect(mock_conn, mock_driver_dialect) is True + + # Writer query answers with a falsy value: not a Multi-AZ cluster. + mock_cursor.fetchone.return_value = (None,) + assert dialect.is_dialect(mock_conn, mock_driver_dialect) is False + + mock_cursor.fetchone.return_value = None + assert dialect.is_dialect(mock_conn, mock_driver_dialect) is False + + # The query raising (rds_tools absent) means "not this dialect", not a + # propagated error. + mock_cursor.execute.side_effect = Exception("function does not exist") + mock_driver_dialect.is_in_transaction.return_value = False + assert dialect.is_dialect(mock_conn, mock_driver_dialect) is False + mock_cursor.execute.side_effect = None def test_get_dialect_custom_dialect(mock_custom_dialect, mock_driver_dialect): diff --git a/tests/unit/test_multi_az_rds_host_list_provider.py b/tests/unit/test_multi_az_rds_host_list_provider.py index c15dd6c58..bcc3f9dde 100644 --- a/tests/unit/test_multi_az_rds_host_list_provider.py +++ b/tests/unit/test_multi_az_rds_host_list_provider.py @@ -211,3 +211,29 @@ def test_query_for_topology__empty_writer_query_results( result = provider._topology_utils._query_for_topology(mock_conn) assert result == queried_hosts + + +def test_unconfirmed_dialect_reports_the_initial_hosts_without_querying( + mocker, mock_provider_service, mock_conn, props): + """Sync half of the dialect gate. Until the dialect is verified the + provider must report the connection-string host and must not query, since + the topology SQL it would run belongs to the unverified guess. + + This gate had no test on either engine: both conditions could be replaced + with ``if False:`` and the full suite stayed green. + """ + mock_provider_service.is_dialect_confirmed = False + provider = create_provider(mock_provider_service, props) + provider._initialize() + mock_force_refresh = mocker.patch.object(provider, '_force_refresh_monitor') + + assert provider.refresh(mock_conn) == provider._initial_hosts + mock_force_refresh.assert_not_called() + + provider.force_refresh(mock_conn) + mock_force_refresh.assert_not_called() + + # Confirmed: the gate opens and the monitor is consulted. + mock_provider_service.is_dialect_confirmed = True + provider.force_refresh(mock_conn) + mock_force_refresh.assert_called()