Skip to content

Commit dd79c49

Browse files
authored
fix various Jinja plugin caching issues (ansible#79781)
* fix various Jinja plugin caching issues * consolidate the wrapper plugin cache * remove redundant cache in J2 filter/test interceptor * intra-template loader bypass * fix early exits swallowing some exception detail * misc comment cleanup
1 parent 4d40988 commit dd79c49

File tree

4 files changed

+77
-94
lines changed

4 files changed

+77
-94
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
bugfixes:
2+
- PluginLoader - fix Jinja plugin performance issues (https://github.com/ansible/ansible/issues/79652)

lib/ansible/plugins/loader.py

Lines changed: 65 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
from traceback import format_exc
1919

2020
import ansible.module_utils.compat.typing as t
21+
22+
from .filter import AnsibleJinja2Filter
23+
from .test import AnsibleJinja2Test
24+
2125
from ansible import __version__ as ansible_version
2226
from ansible import constants as C
2327
from ansible.errors import AnsibleError, AnsiblePluginCircularRedirect, AnsiblePluginRemovedError, AnsibleCollectionUnsupportedVersionError
@@ -1067,28 +1071,17 @@ class Jinja2Loader(PluginLoader):
10671071
We need to do a few things differently in the base class because of file == plugin
10681072
assumptions and dedupe logic.
10691073
"""
1070-
def __init__(self, class_name, package, config, subdir, aliases=None, required_base_class=None):
1071-
1074+
def __init__(self, class_name, package, config, subdir, plugin_wrapper_type, aliases=None, required_base_class=None):
10721075
super(Jinja2Loader, self).__init__(class_name, package, config, subdir, aliases=aliases, required_base_class=required_base_class)
1073-
self._loaded_j2_file_maps = []
1076+
self._plugin_wrapper_type = plugin_wrapper_type
1077+
self._cached_non_collection_wrappers = {}
10741078

10751079
def _clear_caches(self):
10761080
super(Jinja2Loader, self)._clear_caches()
1077-
self._loaded_j2_file_maps = []
1081+
self._cached_non_collection_wrappers = {}
10781082

10791083
def find_plugin(self, name, mod_type='', ignore_deprecated=False, check_aliases=False, collection_list=None):
1080-
1081-
# TODO: handle collection plugin find, see 'get_with_context'
1082-
# this can really 'find plugin file'
1083-
plugin = super(Jinja2Loader, self).find_plugin(name, mod_type=mod_type, ignore_deprecated=ignore_deprecated, check_aliases=check_aliases,
1084-
collection_list=collection_list)
1085-
1086-
# if not found, try loading all non collection plugins and see if this in there
1087-
if not plugin:
1088-
all_plugins = self.all()
1089-
plugin = all_plugins.get(name, None)
1090-
1091-
return plugin
1084+
raise NotImplementedError('find_plugin is not supported on Jinja2Loader')
10921085

10931086
@property
10941087
def method_map_name(self):
@@ -1122,36 +1115,36 @@ def get_contained_plugins(self, collection, plugin_path, name):
11221115
for func_name, func in plugin_map:
11231116
fq_name = '.'.join((collection, func_name))
11241117
full = '.'.join((full_name, func_name))
1125-
pclass = self._load_jinja2_class()
1126-
plugin = pclass(func)
1118+
plugin = self._plugin_wrapper_type(func)
11271119
if plugin in plugins:
11281120
continue
11291121
self._update_object(plugin, full, plugin_path, resolved=fq_name)
11301122
plugins.append(plugin)
11311123

11321124
return plugins
11331125

1126+
# FUTURE: now that the resulting plugins are closer, refactor base class method with some extra
1127+
# hooks so we can avoid all the duplicated plugin metadata logic, and also cache the collection results properly here
11341128
def get_with_context(self, name, *args, **kwargs):
1135-
1136-
# found_in_cache = True
1137-
class_only = kwargs.pop('class_only', False) # just pop it, dont want to pass through
1138-
collection_list = kwargs.pop('collection_list', None)
1129+
# pop N/A kwargs to avoid passthrough to parent methods
1130+
kwargs.pop('class_only', False)
1131+
kwargs.pop('collection_list', None)
11391132

11401133
context = PluginLoadContext()
11411134

11421135
# avoid collection path for legacy
11431136
name = name.removeprefix('ansible.legacy.')
11441137

1145-
if '.' not in name:
1146-
# Filter/tests must always be FQCN except builtin and legacy
1147-
for known_plugin in self.all(*args, **kwargs):
1148-
if known_plugin.matches_name([name]):
1149-
context.resolved = True
1150-
context.plugin_resolved_name = name
1151-
context.plugin_resolved_path = known_plugin._original_path
1152-
context.plugin_resolved_collection = 'ansible.builtin' if known_plugin.ansible_name.startswith('ansible.builtin.') else ''
1153-
context._resolved_fqcn = known_plugin.ansible_name
1154-
return get_with_context_result(known_plugin, context)
1138+
self._ensure_non_collection_wrappers(*args, **kwargs)
1139+
1140+
# check for stuff loaded via legacy/builtin paths first
1141+
if known_plugin := self._cached_non_collection_wrappers.get(name):
1142+
context.resolved = True
1143+
context.plugin_resolved_name = name
1144+
context.plugin_resolved_path = known_plugin._original_path
1145+
context.plugin_resolved_collection = 'ansible.builtin' if known_plugin.ansible_name.startswith('ansible.builtin.') else ''
1146+
context._resolved_fqcn = known_plugin.ansible_name
1147+
return get_with_context_result(known_plugin, context)
11551148

11561149
plugin = None
11571150
key, leaf_key = get_fqcr_and_name(name)
@@ -1237,14 +1230,10 @@ def get_with_context(self, name, *args, **kwargs):
12371230
# use 'parent' loader class to find files, but cannot return this as it can contain
12381231
# multiple plugins per file
12391232
plugin_impl = super(Jinja2Loader, self).get_with_context(module_name, *args, **kwargs)
1240-
except Exception as e:
1241-
raise KeyError(to_native(e))
1242-
1243-
try:
12441233
method_map = getattr(plugin_impl.object, self.method_map_name)
12451234
plugin_map = method_map().items()
12461235
except Exception as e:
1247-
display.warning("Skipping %s plugins in '%s' as it seems to be invalid: %r" % (self.type, to_text(plugin_impl.object._original_path), e))
1236+
display.warning(f"Skipping {self.type} plugins in {module_name}'; an error occurred while loading: {e}")
12481237
continue
12491238

12501239
for func_name, func in plugin_map:
@@ -1253,11 +1242,11 @@ def get_with_context(self, name, *args, **kwargs):
12531242
# TODO: load anyways into CACHE so we only match each at end of loop
12541243
# the files themseves should already be cached by base class caching of modules(python)
12551244
if key in (func_name, fq_name):
1256-
pclass = self._load_jinja2_class()
1257-
plugin = pclass(func)
1245+
plugin = self._plugin_wrapper_type(func)
12581246
if plugin:
12591247
context = plugin_impl.plugin_load_context
12601248
self._update_object(plugin, src_name, plugin_impl.object._original_path, resolved=fq_name)
1249+
# FIXME: once we start caching these results, we'll be missing functions that would have loaded later
12611250
break # go to next file as it can override if dupe (dont break both loops)
12621251

12631252
except AnsiblePluginRemovedError as apre:
@@ -1272,18 +1261,27 @@ def get_with_context(self, name, *args, **kwargs):
12721261
return get_with_context_result(plugin, context)
12731262

12741263
def all(self, *args, **kwargs):
1275-
1276-
# inputs, we ignore 'dedupe' we always do, used in base class to find files for this one
1264+
kwargs.pop('_dedupe', None)
12771265
path_only = kwargs.pop('path_only', False)
12781266
class_only = kwargs.pop('class_only', False) # basically ignored for test/filters since they are functions
12791267

12801268
# Having both path_only and class_only is a coding bug
12811269
if path_only and class_only:
12821270
raise AnsibleError('Do not set both path_only and class_only when calling PluginLoader.all()')
12831271

1284-
found = set()
1272+
self._ensure_non_collection_wrappers(*args, **kwargs)
1273+
if path_only:
1274+
yield from (w._original_path for w in self._cached_non_collection_wrappers.values())
1275+
else:
1276+
yield from (w for w in self._cached_non_collection_wrappers.values())
1277+
1278+
def _ensure_non_collection_wrappers(self, *args, **kwargs):
1279+
if self._cached_non_collection_wrappers:
1280+
return
1281+
12851282
# get plugins from files in configured paths (multiple in each)
1286-
for p_map in self._j2_all_file_maps(*args, **kwargs):
1283+
for p_map in super(Jinja2Loader, self).all(*args, **kwargs):
1284+
is_builtin = p_map.ansible_name.startswith('ansible.builtin.')
12871285

12881286
# p_map is really object from file with class that holds multiple plugins
12891287
plugins_list = getattr(p_map, self.method_map_name)
@@ -1294,57 +1292,35 @@ def all(self, *args, **kwargs):
12941292
continue
12951293

12961294
for plugin_name in plugins.keys():
1297-
if plugin_name in _PLUGIN_FILTERS[self.package]:
1298-
display.debug("%s skipped due to a defined plugin filter" % plugin_name)
1295+
if '.' in plugin_name:
1296+
display.debug(f'{plugin_name} skipped in {p_map._original_path}; Jinja plugin short names may not contain "."')
12991297
continue
13001298

1301-
if plugin_name in found:
1302-
display.debug("%s skipped as duplicate" % plugin_name)
1299+
if plugin_name in _PLUGIN_FILTERS[self.package]:
1300+
display.debug("%s skipped due to a defined plugin filter" % plugin_name)
13031301
continue
13041302

1305-
if path_only:
1306-
result = p_map._original_path
1307-
else:
1308-
# loader class is for the file with multiple plugins, but each plugin now has it's own class
1309-
pclass = self._load_jinja2_class()
1310-
result = pclass(plugins[plugin_name]) # if bad plugin, let exception rise
1311-
found.add(plugin_name)
1312-
fqcn = plugin_name
1313-
collection = '.'.join(p_map.ansible_name.split('.')[:2]) if p_map.ansible_name.count('.') >= 2 else ''
1314-
if not plugin_name.startswith(collection):
1315-
fqcn = f"{collection}.{plugin_name}"
1316-
1317-
self._update_object(result, plugin_name, p_map._original_path, resolved=fqcn)
1318-
yield result
1319-
1320-
def _load_jinja2_class(self):
1321-
""" override the normal method of plugin classname as these are used in the generic funciton
1322-
to access the 'multimap' of filter/tests to function, this is a 'singular' plugin for
1323-
each entry.
1324-
"""
1325-
class_name = 'AnsibleJinja2%s' % get_plugin_class(self.class_name).capitalize()
1326-
module = __import__(self.package, fromlist=[class_name])
1327-
1328-
return getattr(module, class_name)
1303+
# the plugin class returned by the loader may host multiple Jinja plugins, but we wrap each plugin in
1304+
# its own surrogate wrapper instance here to ease the bookkeeping...
1305+
wrapper = self._plugin_wrapper_type(plugins[plugin_name])
1306+
fqcn = plugin_name
1307+
collection = '.'.join(p_map.ansible_name.split('.')[:2]) if p_map.ansible_name.count('.') >= 2 else ''
1308+
if not plugin_name.startswith(collection):
1309+
fqcn = f"{collection}.{plugin_name}"
13291310

1330-
def _j2_all_file_maps(self, *args, **kwargs):
1331-
"""
1332-
* Unlike other plugin types, file != plugin, a file can contain multiple plugins (of same type).
1333-
This is why we do not deduplicate ansible file names at this point, we mostly care about
1334-
the names of the actual jinja2 plugins which are inside of our files.
1335-
* This method will NOT fetch collection plugin files, only those that would be expected under 'ansible.builtin/legacy'.
1336-
"""
1337-
# populate cache if needed
1338-
if not self._loaded_j2_file_maps:
1311+
self._update_object(wrapper, plugin_name, p_map._original_path, resolved=fqcn)
13391312

1340-
# We don't deduplicate ansible file names.
1341-
# Instead, calling code deduplicates jinja2 plugin names when loading each file.
1342-
kwargs['_dedupe'] = False
1313+
target_names = {plugin_name, fqcn}
1314+
if is_builtin:
1315+
target_names.add(f'ansible.builtin.{plugin_name}')
13431316

1344-
# To match correct precedence, call base class' all() to get a list of files,
1345-
self._loaded_j2_file_maps = list(super(Jinja2Loader, self).all(*args, **kwargs))
1317+
for target_name in target_names:
1318+
if existing_plugin := self._cached_non_collection_wrappers.get(target_name):
1319+
display.debug(f'Jinja plugin {target_name} from {p_map._original_path} skipped; '
1320+
f'shadowed by plugin from {existing_plugin._original_path})')
1321+
continue
13461322

1347-
return self._loaded_j2_file_maps
1323+
self._cached_non_collection_wrappers[target_name] = wrapper
13481324

13491325

13501326
def get_fqcr_and_name(resource, collection='ansible.builtin'):
@@ -1572,13 +1548,15 @@ def init_plugin_loader(prefix_collections_path=None):
15721548
'ansible.plugins.filter',
15731549
C.DEFAULT_FILTER_PLUGIN_PATH,
15741550
'filter_plugins',
1551+
AnsibleJinja2Filter
15751552
)
15761553

15771554
test_loader = Jinja2Loader(
15781555
'TestModule',
15791556
'ansible.plugins.test',
15801557
C.DEFAULT_TEST_PLUGIN_PATH,
1581-
'test_plugins'
1558+
'test_plugins',
1559+
AnsibleJinja2Test
15821560
)
15831561

15841562
strategy_loader = PluginLoader(

lib/ansible/template/__init__.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -445,19 +445,22 @@ def __init__(self, delegatee, pluginloader, *args, **kwargs):
445445

446446
self._pluginloader = pluginloader
447447

448-
# cache of resolved plugins
448+
# Jinja environment's mapping of known names (initially just J2 builtins)
449449
self._delegatee = delegatee
450450

451-
# track loaded plugins here as cache above includes 'jinja2' filters but ours should override
452-
self._loaded_builtins = set()
451+
# our names take precedence over Jinja's, but let things we've tried to resolve skip the pluginloader
452+
self._seen_it = set()
453453

454454
def __getitem__(self, key):
455455

456456
if not isinstance(key, string_types):
457457
raise ValueError('key must be a string, got %s instead' % type(key))
458458

459459
original_exc = None
460-
if key not in self._loaded_builtins:
460+
if key not in self._seen_it:
461+
# this looks too early to set this- it isn't. Setting it here keeps requests for Jinja builtins from
462+
# going through the pluginloader more than once, which is extremely slow for something that won't ever succeed.
463+
self._seen_it.add(key)
461464
plugin = None
462465
try:
463466
plugin = self._pluginloader.get(key)
@@ -471,12 +474,12 @@ def __getitem__(self, key):
471474
if plugin:
472475
# set in filter cache and avoid expensive plugin load
473476
self._delegatee[key] = plugin.j2_function
474-
self._loaded_builtins.add(key)
475477

476478
# raise template syntax error if we could not find ours or jinja2 one
477479
try:
478480
func = self._delegatee[key]
479481
except KeyError as e:
482+
self._seen_it.remove(key)
480483
raise TemplateSyntaxError('Could not load "%s": %s' % (key, to_native(original_exc or e)), 0)
481484

482485
# if i do have func and it is a filter, it nees wrapping

test/integration/targets/collections/posix.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,8 @@
151151

152152
- assert:
153153
that:
154-
- |
155-
'This is a broken filter plugin.' in result.msg
154+
# FUTURE: ensure that the warning was also issued with the actual failure details
155+
- result is failed
156156

157157
- debug:
158158
msg: "{{ 'foo'|missing.collection.filter }}"

0 commit comments

Comments
 (0)