Skip to content

Commit 6a70dae

Browse files
{Core} using threads to build command table for performance (#32518)
1 parent 7910b8b commit 6a70dae

File tree

5 files changed

+181
-47
lines changed

5 files changed

+181
-47
lines changed

src/azure-cli-core/azure/cli/core/__init__.py

Lines changed: 116 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import os
1010
import sys
1111
import timeit
12+
import concurrent.futures
13+
from concurrent.futures import ThreadPoolExecutor
1214

1315
from knack.cli import CLI
1416
from knack.commands import CLICommandsLoader
@@ -32,6 +34,10 @@
3234
ALWAYS_LOADED_MODULES = []
3335
# Extensions that will always be loaded if installed. They don't expose commands but hook into CLI core.
3436
ALWAYS_LOADED_EXTENSIONS = ['azext_ai_examples', 'azext_next']
37+
# Timeout (in seconds) for loading a single module. Acts as a safety valve to prevent indefinite hangs
38+
MODULE_LOAD_TIMEOUT_SECONDS = 30
39+
# Maximum number of worker threads for parallel module loading.
40+
MAX_WORKER_THREAD_COUNT = 4
3541

3642

3743
def _configure_knack():
@@ -195,6 +201,17 @@ def _configure_style(self):
195201
format_styled_text.theme = theme
196202

197203

204+
class ModuleLoadResult: # pylint: disable=too-few-public-methods
205+
def __init__(self, module_name, command_table, group_table, elapsed_time, error=None, traceback_str=None, command_loader=None):
206+
self.module_name = module_name
207+
self.command_table = command_table
208+
self.group_table = group_table
209+
self.elapsed_time = elapsed_time
210+
self.error = error
211+
self.traceback_str = traceback_str
212+
self.command_loader = command_loader
213+
214+
198215
class MainCommandsLoader(CLICommandsLoader):
199216

200217
# Format string for pretty-print the command module table
@@ -221,11 +238,11 @@ def load_command_table(self, args):
221238
import pkgutil
222239
import traceback
223240
from azure.cli.core.commands import (
224-
_load_module_command_loader, _load_extension_command_loader, BLOCKED_MODS, ExtensionCommandSource)
241+
_load_extension_command_loader, ExtensionCommandSource)
225242
from azure.cli.core.extension import (
226243
get_extensions, get_extension_path, get_extension_modname)
227244
from azure.cli.core.breaking_change import (
228-
import_core_breaking_changes, import_module_breaking_changes, import_extension_breaking_changes)
245+
import_core_breaking_changes, import_extension_breaking_changes)
229246

230247
def _update_command_table_from_modules(args, command_modules=None):
231248
"""Loads command tables from modules and merge into the main command table.
@@ -253,38 +270,10 @@ def _update_command_table_from_modules(args, command_modules=None):
253270
except ImportError as e:
254271
logger.warning(e)
255272

256-
count = 0
257-
cumulative_elapsed_time = 0
258-
cumulative_group_count = 0
259-
cumulative_command_count = 0
260-
logger.debug("Loading command modules:")
261-
logger.debug(self.header_mod)
273+
results = self._load_modules(args, command_modules)
262274

263-
for mod in [m for m in command_modules if m not in BLOCKED_MODS]:
264-
try:
265-
start_time = timeit.default_timer()
266-
module_command_table, module_group_table = _load_module_command_loader(self, args, mod)
267-
import_module_breaking_changes(mod)
268-
for cmd in module_command_table.values():
269-
cmd.command_source = mod
270-
self.command_table.update(module_command_table)
271-
self.command_group_table.update(module_group_table)
272-
273-
elapsed_time = timeit.default_timer() - start_time
274-
logger.debug(self.item_format_string, mod, elapsed_time,
275-
len(module_group_table), len(module_command_table))
276-
count += 1
277-
cumulative_elapsed_time += elapsed_time
278-
cumulative_group_count += len(module_group_table)
279-
cumulative_command_count += len(module_command_table)
280-
except Exception as ex: # pylint: disable=broad-except
281-
# Changing this error message requires updating CI script that checks for failed
282-
# module loading.
283-
from azure.cli.core import telemetry
284-
logger.error("Error loading command module '%s': %s", mod, ex)
285-
telemetry.set_exception(exception=ex, fault_type='module-load-error-' + mod,
286-
summary='Error loading module: {}'.format(mod))
287-
logger.debug(traceback.format_exc())
275+
count, cumulative_elapsed_time, cumulative_group_count, cumulative_command_count = \
276+
self._process_results_with_timing(results)
288277
# Summary line
289278
logger.debug(self.item_format_string,
290279
"Total ({})".format(count), cumulative_elapsed_time,
@@ -358,7 +347,7 @@ def _filter_modname(extensions):
358347
# from an extension requires this map to be up-to-date.
359348
# self._mod_to_ext_map[ext_mod] = ext_name
360349
start_time = timeit.default_timer()
361-
extension_command_table, extension_group_table = \
350+
extension_command_table, extension_group_table, _ = \
362351
_load_extension_command_loader(self, args, ext_mod)
363352
import_extension_breaking_changes(ext_mod)
364353

@@ -561,6 +550,99 @@ def load_arguments(self, command=None):
561550
self.extra_argument_registry.update(loader.extra_argument_registry)
562551
loader._update_command_definitions() # pylint: disable=protected-access
563552

553+
def _load_modules(self, args, command_modules):
554+
"""Load command modules using ThreadPoolExecutor with timeout protection."""
555+
from azure.cli.core.commands import BLOCKED_MODS
556+
557+
results = []
558+
with ThreadPoolExecutor(max_workers=MAX_WORKER_THREAD_COUNT) as executor:
559+
future_to_module = {executor.submit(self._load_single_module, mod, args): mod
560+
for mod in command_modules if mod not in BLOCKED_MODS}
561+
562+
for future in concurrent.futures.as_completed(future_to_module):
563+
try:
564+
result = future.result(timeout=MODULE_LOAD_TIMEOUT_SECONDS)
565+
results.append(result)
566+
except concurrent.futures.TimeoutError:
567+
mod = future_to_module[future]
568+
logger.warning("Module '%s' load timeout after %s seconds", mod, MODULE_LOAD_TIMEOUT_SECONDS)
569+
results.append(ModuleLoadResult(mod, {}, {}, 0,
570+
Exception(f"Module '{mod}' load timeout")))
571+
except (ImportError, AttributeError, TypeError, ValueError) as ex:
572+
mod = future_to_module[future]
573+
logger.warning("Module '%s' load failed: %s", mod, ex)
574+
results.append(ModuleLoadResult(mod, {}, {}, 0, ex))
575+
except Exception as ex: # pylint: disable=broad-exception-caught
576+
mod = future_to_module[future]
577+
logger.warning("Module '%s' load failed with unexpected exception: %s", mod, ex)
578+
results.append(ModuleLoadResult(mod, {}, {}, 0, ex))
579+
580+
return results
581+
582+
def _load_single_module(self, mod, args):
583+
from azure.cli.core.breaking_change import import_module_breaking_changes
584+
from azure.cli.core.commands import _load_module_command_loader
585+
import traceback
586+
try:
587+
start_time = timeit.default_timer()
588+
module_command_table, module_group_table, command_loader = _load_module_command_loader(self, args, mod)
589+
import_module_breaking_changes(mod)
590+
elapsed_time = timeit.default_timer() - start_time
591+
return ModuleLoadResult(mod, module_command_table, module_group_table, elapsed_time, command_loader=command_loader)
592+
except Exception as ex: # pylint: disable=broad-except
593+
tb_str = traceback.format_exc()
594+
return ModuleLoadResult(mod, {}, {}, 0, ex, tb_str)
595+
596+
def _handle_module_load_error(self, result):
597+
"""Handle errors that occurred during module loading."""
598+
from azure.cli.core import telemetry
599+
600+
logger.error("Error loading command module '%s': %s", result.module_name, result.error)
601+
telemetry.set_exception(exception=result.error,
602+
fault_type='module-load-error-' + result.module_name,
603+
summary='Error loading module: {}'.format(result.module_name))
604+
if result.traceback_str:
605+
logger.debug(result.traceback_str)
606+
607+
def _process_successful_load(self, result):
608+
"""Process successfully loaded module results."""
609+
if result.command_loader:
610+
self.loaders.append(result.command_loader)
611+
612+
for cmd in result.command_table:
613+
self.cmd_to_loader_map[cmd] = [result.command_loader]
614+
615+
for cmd in result.command_table.values():
616+
cmd.command_source = result.module_name
617+
618+
self.command_table.update(result.command_table)
619+
self.command_group_table.update(result.group_table)
620+
621+
logger.debug(self.item_format_string, result.module_name, result.elapsed_time,
622+
len(result.group_table), len(result.command_table))
623+
624+
def _process_results_with_timing(self, results):
625+
"""Process pre-loaded module results with timing and progress reporting."""
626+
logger.debug("Loading command modules:")
627+
logger.debug(self.header_mod)
628+
629+
count = 0
630+
cumulative_elapsed_time = 0
631+
cumulative_group_count = 0
632+
cumulative_command_count = 0
633+
634+
for result in results:
635+
if result.error:
636+
self._handle_module_load_error(result)
637+
else:
638+
self._process_successful_load(result)
639+
count += 1
640+
cumulative_elapsed_time += result.elapsed_time
641+
cumulative_group_count += len(result.group_table)
642+
cumulative_command_count += len(result.command_table)
643+
644+
return count, cumulative_elapsed_time, cumulative_group_count, cumulative_command_count
645+
564646

565647
class CommandIndex:
566648

src/azure-cli-core/azure/cli/core/commands/__init__.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1134,22 +1134,17 @@ def _load_command_loader(loader, args, name, prefix):
11341134
logger.debug("Module '%s' is missing `get_command_loader` entry.", name)
11351135

11361136
command_table = {}
1137+
command_loader = None
11371138

11381139
if loader_cls:
11391140
command_loader = loader_cls(cli_ctx=loader.cli_ctx)
1140-
loader.loaders.append(command_loader) # This will be used by interactive
11411141
if command_loader.supported_resource_type():
11421142
command_table = command_loader.load_command_table(args)
1143-
if command_table:
1144-
for cmd in list(command_table.keys()):
1145-
# TODO: If desired to for extension to patch module, this can be uncommented
1146-
# if loader.cmd_to_loader_map.get(cmd):
1147-
# loader.cmd_to_loader_map[cmd].append(command_loader)
1148-
# else:
1149-
loader.cmd_to_loader_map[cmd] = [command_loader]
11501143
else:
11511144
logger.debug("Module '%s' is missing `COMMAND_LOADER_CLS` entry.", name)
1152-
return command_table, command_loader.command_group_table
1145+
1146+
group_table = command_loader.command_group_table if command_loader else {}
1147+
return command_table, group_table, command_loader
11531148

11541149

11551150
def _load_extension_command_loader(loader, args, ext):

src/azure-cli-core/azure/cli/core/tests/test_command_registration.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ def load_command_table(self, args):
230230
if command_table:
231231
module_command_table.update(command_table)
232232
loader.loaders.append(command_loader) # this will be used later by the load_arguments method
233-
return module_command_table, command_loader.command_group_table
233+
return module_command_table, command_loader.command_group_table, command_loader
234234

235235
expected_command_index = {'hello': ['azure.cli.command_modules.hello', 'azext_hello2', 'azext_hello1'],
236236
'extra': ['azure.cli.command_modules.extra']}
@@ -432,12 +432,12 @@ def test_command_index_positional_argument(self):
432432
# Test command index is built for command with positional argument
433433
cmd_tbl = loader.load_command_table(["extra", "extra", "positional_argument"])
434434
self.assertDictEqual(INDEX[CommandIndex._COMMAND_INDEX], self.expected_command_index)
435-
self.assertEqual(list(cmd_tbl), ['hello mod-only', 'hello overridden', 'extra final', 'hello ext-only'])
435+
self.assertSetEqual(set(cmd_tbl), {'hello mod-only', 'hello overridden', 'extra final', 'hello ext-only'})
436436

437437
# Test command index is used by command with positional argument
438438
cmd_tbl = loader.load_command_table(["hello", "mod-only", "positional_argument"])
439439
self.assertDictEqual(INDEX[CommandIndex._COMMAND_INDEX], self.expected_command_index)
440-
self.assertEqual(list(cmd_tbl), ['hello mod-only', 'hello overridden', 'hello ext-only'])
440+
self.assertSetEqual(set(cmd_tbl), {'hello mod-only', 'hello overridden', 'hello ext-only'})
441441

442442
# Test command index is used by command with positional argument
443443
cmd_tbl = loader.load_command_table(["extra", "final", "positional_argument2"])
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# --------------------------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for license information.
4+
# --------------------------------------------------------------------------------------------
5+
6+
import unittest
7+
8+
from azure.cli.core.mock import DummyCli
9+
from azure.cli.core import MainCommandsLoader
10+
11+
12+
class CommandTableIntegrityTest(unittest.TestCase):
13+
14+
def setUp(self):
15+
self.cli_ctx = DummyCli()
16+
17+
def test_command_table_integrity(self):
18+
"""Test command table loading produces valid, complete results."""
19+
20+
# Load command table using current implementation
21+
loader = MainCommandsLoader(self.cli_ctx)
22+
loader.load_command_table([])
23+
24+
# Test invariants that should always hold:
25+
26+
# 1. No corruption/duplicates
27+
command_names = list(loader.command_table.keys())
28+
unique_command_names = set(command_names)
29+
self.assertEqual(len(unique_command_names), len(command_names), "No duplicate commands")
30+
31+
# 2. Core functionality exists (high-level groups that should always exist)
32+
core_groups = ['vm', 'network', 'resource', 'account', 'group']
33+
existing_groups = {cmd.split()[0] for cmd in loader.command_table.keys() if ' ' in cmd}
34+
missing_core = [group for group in core_groups if group not in existing_groups]
35+
self.assertEqual(len(missing_core), 0, f"Missing core command groups: {missing_core}")
36+
37+
# 3. Structural integrity
38+
commands_without_source = []
39+
for cmd_name, cmd_obj in loader.command_table.items():
40+
if not hasattr(cmd_obj, 'command_source') or not cmd_obj.command_source:
41+
commands_without_source.append(cmd_name)
42+
43+
self.assertEqual(len(commands_without_source), 0,
44+
f"Commands missing source: {commands_without_source[:5]}...")
45+
46+
# 4. Basic sanity - we loaded SOMETHING
47+
self.assertGreater(len(loader.command_table), 0, "Commands were loaded")
48+
self.assertGreater(len(loader.command_group_table), 0, "Groups were loaded")
49+
50+
# 5. Verify core groups are properly represented
51+
found_core_groups = sorted(existing_groups & set(core_groups))
52+
self.assertGreaterEqual(len(found_core_groups), 3,
53+
f"At least 3 core command groups should be present, found: {found_core_groups}")
54+
55+
56+
if __name__ == '__main__':
57+
unittest.main()

src/azure-cli-core/azure/cli/core/tests/test_parser.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ def load_command_table(self, args):
188188
if command_table:
189189
module_command_table.update(command_table)
190190
loader.loaders.append(command_loader) # this will be used later by the load_arguments method
191-
return module_command_table, command_loader.command_group_table
191+
return module_command_table, command_loader.command_group_table, command_loader
192192

193193
@mock.patch('importlib.import_module', _mock_import_lib)
194194
@mock.patch('pkgutil.iter_modules', _mock_iter_modules)

0 commit comments

Comments
 (0)