Skip to content

Commit 6d95d5e

Browse files
committed
chore: improve chain creation readability and fix must_use_pipeline dead code
1 parent ecc898e commit 6d95d5e

2 files changed

Lines changed: 151 additions & 24 deletions

File tree

aws_advanced_python_wrapper/plugin_service.py

Lines changed: 49 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -985,9 +985,26 @@ def get_factory_weights(factory_types: List[Type[PluginFactory]]) -> Dict[Type[P
985985

986986
return weights
987987

988-
def must_use_pipeline(self, method: DbApiMethod):
988+
def must_use_pipeline(self, method: DbApiMethod) -> bool:
989+
"""Whether this method has to run through the plugin pipeline.
990+
991+
Mirrors JDBC ``ConnectionPluginManager.mustUsePipeline``: the pipeline is required when
992+
the method always uses it, when the chain has not been built yet (nothing to decide on),
993+
when a real plugin is subscribed, or when telemetry is on (so per-plugin NESTED spans are
994+
still emitted).
995+
996+
Intentional deviation from JDBC: the trailing ``is_network_bound_method`` term. JDBC's
997+
DefaultConnectionPlugin is a thin passthrough, but Python's DefaultPlugin.execute also
998+
applies DriverDialect.execute's socket timeout and its interrupt-and-wait cleanup. Skipping
999+
that for a network-bound method lets a later close/reuse race a still-running operation
1000+
(env-4 SIGSEGV), so those methods stay on the pipeline regardless of subscriptions.
1001+
"""
9891002
plugin_chain_info: Optional[PluginChainCallableInfo] = self._function_cache[method.id]
990-
return method.always_use_pipeline or plugin_chain_info is None or plugin_chain_info.is_subscribed or self._telemetry_in_use
1003+
return (method.always_use_pipeline
1004+
or plugin_chain_info is None
1005+
or plugin_chain_info.is_subscribed
1006+
or self._telemetry_in_use
1007+
or self._container.plugin_service.is_network_bound_method(method.method_name))
9911008

9921009
def execute(self, target: object, method: DbApiMethod, target_driver_func: Callable, *args, **kwargs) -> Any:
9931010
plugin_service = self._container.plugin_service
@@ -1044,36 +1061,44 @@ def _execute_with_subscribed_plugins(
10441061
pipeline_func_info = self._make_pipeline(method.method_name)
10451062
self._function_cache[method.id] = pipeline_func_info
10461063

1047-
# Execute only if method needs to use pipeline, or a plugin is subscribed to this method
1048-
if method.always_use_pipeline or pipeline_func_info.is_subscribed:
1064+
# Execute only if the method needs to use the pipeline, or a plugin is subscribed to it.
1065+
if self.must_use_pipeline(method):
10491066
return pipeline_func_info.func(plugin_func, target_driver_func, method.method_name, plugin_to_skip)
1050-
else:
1051-
return target_driver_func()
1067+
1068+
result = target_driver_func()
1069+
1070+
# DefaultPlugin.execute refreshes the cached in-transaction state after every method except
1071+
# close; failover and read_write_splitting read it to decide whether a transaction is open.
1072+
plugin_service = self._container.plugin_service
1073+
if method != DbApiMethod.CONNECTION_CLOSE and plugin_service.current_connection is not None:
1074+
plugin_service.update_in_transaction()
1075+
1076+
return result
1077+
1078+
def _subscribed_plugins(self, method_name: str) -> List[Plugin]:
1079+
all_methods_marker = DbApiMethod.ALL.method_name
1080+
return [
1081+
plugin for plugin in self._plugins
1082+
if all_methods_marker in plugin.subscribed_methods or method_name in plugin.subscribed_methods
1083+
]
10521084

10531085
# Builds the plugin pipeline function chain. The pipeline is built in a way that allows plugins to perform logic
10541086
# both before and after the target driver function call.
10551087
def _make_pipeline(self, method_name: str) -> PluginChainCallableInfo:
1056-
pipeline_func: Optional[Callable] = None
1057-
num_plugins: int = len(self._plugins)
1058-
is_subscribed: bool = False
1059-
1060-
# Build the pipeline starting at the end and working backwards
1061-
for i in range(num_plugins - 1, -1, -1):
1062-
plugin: Plugin = self._plugins[i]
1088+
subscribed = self._subscribed_plugins(method_name)
1089+
if not subscribed:
1090+
raise AwsWrapperError(Messages.get("PluginManager.PipelineNone"))
10631091

1064-
subscribed_methods: Set[str] = plugin.subscribed_methods
1065-
is_plugin_subscribed = DbApiMethod.ALL.method_name in subscribed_methods or method_name in subscribed_methods
1066-
is_subscribed |= is_plugin_subscribed
1092+
# DefaultPlugin subscribes to "*" and is appended to every plugin list, so counting it here would
1093+
# pin is_subscribed to True for every method and make the bypass in _execute_with_subscribed_plugins
1094+
# unreachable.
1095+
is_subscribed = any(not isinstance(plugin, DefaultPlugin) for plugin in subscribed)
10671096

1068-
if is_plugin_subscribed:
1069-
if pipeline_func is None:
1070-
# Defines the call to DefaultPlugin, which is the last plugin in the pipeline
1071-
pipeline_func = self._create_base_pipeline_func(plugin)
1072-
continue
1073-
pipeline_func = self._extend_pipeline_func(plugin, pipeline_func)
1097+
# Build the pipeline starting at the end and working backwards
1098+
pipeline_func = self._create_base_pipeline_func(subscribed[-1])
1099+
for plugin in reversed(subscribed[:-1]):
1100+
pipeline_func = self._extend_pipeline_func(plugin, pipeline_func)
10741101

1075-
if pipeline_func is None:
1076-
raise AwsWrapperError(Messages.get("PluginManager.PipelineNone"))
10771102
return PluginChainCallableInfo(pipeline_func, is_subscribed)
10781103

10791104
def _create_base_pipeline_func(self, plugin: Plugin):

tests/unit/test_plugin_manager.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,3 +610,105 @@ def notify_host_list_changed(self, changes: Dict[str, Set[HostEvent]]):
610610
def notify_connection_changed(self, changes: Set[ConnectionEvent]) -> OldConnectionSuggestedAction:
611611
self._calls.append(type(self).__name__ + ":notify_connection_changed")
612612
raise AwsWrapperError()
613+
614+
615+
def test_default_plugin_excluded_from_is_subscribed(mocker, mock_telemetry_factory):
616+
# DefaultPlugin subscribes to "*", but it must not mark a method as subscribed on its own --
617+
# otherwise the direct-call bypass in _execute_with_subscribed_plugins is unreachable.
618+
mocker.patch.object(PluginManager, "__init__", lambda w, x, y, z: None)
619+
manager = PluginManager(mocker.MagicMock(), mocker.MagicMock(), mocker.MagicMock())
620+
manager._plugins = [DefaultPlugin(mocker.MagicMock(), mocker.MagicMock())]
621+
manager._telemetry_factory = mock_telemetry_factory
622+
623+
assert not manager._make_pipeline(DbApiMethod.CURSOR_EXECUTE.method_name).is_subscribed
624+
assert not manager._make_pipeline(DbApiMethod.CONNECT.method_name).is_subscribed
625+
626+
# A real subscribing plugin still sets the flag, and only for the methods it subscribes to.
627+
manager._plugins = [TestPluginTwo([]), DefaultPlugin(mocker.MagicMock(), mocker.MagicMock())]
628+
assert manager._make_pipeline(DbApiMethodTest.TEST_CALL_A.method_name).is_subscribed
629+
assert not manager._make_pipeline(DbApiMethod.CURSOR_FETCHALL.method_name).is_subscribed
630+
631+
632+
def test_unsubscribed_method_bypasses_pipeline(mocker, container, mock_telemetry_factory):
633+
# With only DefaultPlugin in the chain, a non-network-bound method skips the pipeline entirely
634+
# but must still refresh the cached in-transaction state.
635+
calls = []
636+
container.plugin_service.is_network_bound_method.side_effect = \
637+
lambda name: name == DbApiMethod.CURSOR_EXECUTE.method_name
638+
container.plugin_service.update_in_transaction.side_effect = \
639+
lambda *args: calls.append("update_in_transaction")
640+
container.plugin_service.driver_dialect.execute.side_effect = \
641+
lambda method_name, func, *args, **kwargs: (calls.append("dialect.execute"), func())[1]
642+
643+
mocker.patch.object(PluginManager, "__init__", lambda w, x, y, z: None)
644+
manager = PluginManager(mocker.MagicMock(), mocker.MagicMock(), mocker.MagicMock())
645+
manager._plugins = [DefaultPlugin(container.plugin_service, mocker.MagicMock())]
646+
manager._container = container
647+
manager._telemetry_factory = mock_telemetry_factory
648+
manager._telemetry_factory.open_telemetry_context.return_value = None
649+
manager._telemetry_in_use = False
650+
manager._function_cache = [None] * (DbApiMethod.ALL.id + 1)
651+
652+
def _execute(method):
653+
return manager._execute_with_subscribed_plugins(
654+
method,
655+
lambda plugin, next_func: plugin.execute(mocker.MagicMock(), method.method_name, next_func),
656+
lambda: (calls.append("target"), "result_value")[1])
657+
658+
# Not network bound -> bypass, no DriverDialect.execute, transaction state still updated.
659+
assert _execute(DbApiMethod.CURSOR_LASTROWID) == "result_value"
660+
assert calls == ["target", "update_in_transaction"]
661+
662+
# Network bound -> stays on the pipeline so the socket timeout guard is preserved.
663+
calls.clear()
664+
assert _execute(DbApiMethod.CURSOR_EXECUTE) == "result_value"
665+
assert calls == ["dialect.execute", "target", "update_in_transaction"]
666+
667+
# Telemetry on -> back on the pipeline even for the otherwise-bypassable method, so the
668+
# per-plugin NESTED spans are still emitted.
669+
calls.clear()
670+
manager._telemetry_in_use = True
671+
manager._function_cache = [None] * (DbApiMethod.ALL.id + 1)
672+
assert _execute(DbApiMethod.CURSOR_LASTROWID) == "result_value"
673+
assert calls == ["dialect.execute", "target", "update_in_transaction"]
674+
675+
676+
def test_must_use_pipeline(mocker, container, mock_telemetry_factory):
677+
# must_use_pipeline is the single authority for the bypass decision, so each term matters.
678+
container.plugin_service.is_network_bound_method.side_effect = \
679+
lambda name: name == DbApiMethod.CURSOR_EXECUTE.method_name
680+
681+
mocker.patch.object(PluginManager, "__init__", lambda w, x, y, z: None)
682+
manager = PluginManager(mocker.MagicMock(), mocker.MagicMock(), mocker.MagicMock())
683+
manager._plugins = [DefaultPlugin(container.plugin_service, mocker.MagicMock())]
684+
manager._container = container
685+
manager._telemetry_factory = mock_telemetry_factory
686+
manager._telemetry_in_use = False
687+
manager._function_cache = [None] * (DbApiMethod.ALL.id + 1)
688+
689+
# Chain not built yet -> nothing to decide on, so the pipeline is required.
690+
assert manager.must_use_pipeline(DbApiMethod.CURSOR_LASTROWID)
691+
692+
# Built, unsubscribed, not network bound, telemetry off -> bypass allowed.
693+
manager._function_cache[DbApiMethod.CURSOR_LASTROWID.id] = \
694+
manager._make_pipeline(DbApiMethod.CURSOR_LASTROWID.method_name)
695+
assert not manager.must_use_pipeline(DbApiMethod.CURSOR_LASTROWID)
696+
697+
# always_use_pipeline and network-bound methods are always required.
698+
assert manager.must_use_pipeline(DbApiMethod.CONNECT)
699+
manager._function_cache[DbApiMethod.CURSOR_EXECUTE.id] = \
700+
manager._make_pipeline(DbApiMethod.CURSOR_EXECUTE.method_name)
701+
assert manager.must_use_pipeline(DbApiMethod.CURSOR_EXECUTE)
702+
703+
# Telemetry re-enables the pipeline for the otherwise-bypassable method.
704+
manager._telemetry_in_use = True
705+
assert manager.must_use_pipeline(DbApiMethod.CURSOR_LASTROWID)
706+
707+
# A real subscribed plugin also forces the pipeline.
708+
subscriber = mocker.MagicMock()
709+
subscriber.subscribed_methods = {DbApiMethod.CURSOR_LASTROWID.method_name}
710+
manager._plugins = [subscriber, DefaultPlugin(container.plugin_service, mocker.MagicMock())]
711+
manager._telemetry_in_use = False
712+
manager._function_cache[DbApiMethod.CURSOR_LASTROWID.id] = \
713+
manager._make_pipeline(DbApiMethod.CURSOR_LASTROWID.method_name)
714+
assert manager.must_use_pipeline(DbApiMethod.CURSOR_LASTROWID)

0 commit comments

Comments
 (0)