Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Optimize usage calculation in ILM policies retrieval API #106953

Open
wants to merge 9 commits into
base: main
Choose a base branch
from

Conversation

nielsbauman
Copy link
Contributor

@nielsbauman nielsbauman commented Apr 1, 2024

Optimize calculating the usage of ILM policies in the GET _ilm/policy and GET _ilm/policy/<policy_id> endpoints. I loaded some dummy data into my single-node locally-running ES cluster using the following script (numbers are inspired by a customer who was facing timeouts in Kibana due to this endpoint being slow):

Bash snippet
#!/bin/bash

ES_URL="${ES_URL:-http://localhost:9200}"
NR_POLICIES=42
NR_COMPONENT_TEMPLATES=700
NR_INDEX_TEMPLATES=8000
NR_DATA_STREAMS=1000
NR_INDICES=1000

for (( i=0; i<NR_POLICIES; i++ )); do
  curl -s -X PUT "$ES_URL/_ilm/policy/policy-$i" -H 'Content-Type: application/json' -d '
  {
    "policy": {
      "phases": {}
    }
  }
  ' > /dev/null
done


for (( i=0; i<NR_COMPONENT_TEMPLATES; i++ )); do
  curl -s -X PUT "$ES_URL/_component_template/component-template-$i" -H 'Content-Type: application/json' -d '
  {"template": {}}
  ' > /dev/null
done

for (( i=0; i<NR_INDEX_TEMPLATES; i++ )); do
  component_template=$((RANDOM % NR_COMPONENT_TEMPLATES))
  curl -s -X PUT "$ES_URL/_index_template/index-template-$i" -H 'Content-Type: application/json' -d "
  {
    \"index_patterns\": [\"index-pattern-$i\"],
    \"template\": {},
    \"composed_of\": [\"component-template-$component_template\"],
    \"data_stream\": {}
  }
  " > /dev/null
done

for (( i=0; i<NR_DATA_STREAMS; i++ )); do
  curl -s -X PUT "$ES_URL/_data_stream/index-pattern-$i" > /dev/null
done

for (( i=0; i<NR_INDICES; i++ )); do
  curl -s -X PUT "$ES_URL/index-$i" > /dev/null
done

I also played around with some aspects of the script such as making the templates use policies or not, as that represents different scenarios that users might face.

I generated a flamegraph on main, which showed that the MetadataIndexTemplateService.findV2Template(...) call was by far the most expensive.

Flamegraph

image

After some experimentation, I ended up extracting a separate class that pre-computes some parts on initialization (i.e. only once per request) and then uses those pre-computed parts when calculating the usage for an individual policy. This is of course a trade-off between memory usage and run-time performance. But, I think the added memory usage (which shouldn't be crazy much) is worth the significant speed improvement here.

Fixes #105773

@nielsbauman nielsbauman added >enhancement :Data Management/ILM+SLM Index and Snapshot lifecycle management Team:Data Management Meta label for data/management team v8.14.0 labels Apr 1, 2024
@elasticsearchmachine
Copy link
Collaborator

Pinging @elastic/es-data-management (Team:Data Management)

@elasticsearchmachine
Copy link
Collaborator

Hi @nielsbauman, I've created a changelog YAML for you.

@gmarouli gmarouli self-requested a review April 3, 2024 10:18
@gmarouli
Copy link
Contributor

gmarouli commented Apr 3, 2024

I really like the idea of reducing the number of templates we are going through to speed up things. However, I am afraid this optimisation is not correct, I created a test that demonstrates the bug:

        {
            // Test when a data stream does not use the policy anymore because of a higher template
            Metadata.Builder mBuilder = Metadata.builder()
                   .put(IndexMetadata.builder("myindex").settings(indexSettings(IndexVersion.current(), 1, 0).put(LifecycleSettings.LIFECYCLE_NAME, "mypolicy")))
                    .putCustom(
                            IndexLifecycleMetadata.TYPE,
                            new IndexLifecycleMetadata(
                                    Map.of("mypolicy", LifecyclePolicyMetadataTests.createRandomPolicyMetadata("mypolicy")),
                                    OperationMode.RUNNING
                            )
                    )
                    .putCustom(
                            ComposableIndexTemplateMetadata.TYPE,
                            new ComposableIndexTemplateMetadata(
                                    Map.of(
                                            "mytemplate",
                                            ComposableIndexTemplate.builder()
                                                    .indexPatterns(Collections.singletonList("myds*"))
                                                    .template(
                                                            new Template(Settings.builder().put(LifecycleSettings.LIFECYCLE_NAME, "mypolicy").build(), null, null)
                                                    )
                                                    .dataStreamTemplate(new ComposableIndexTemplate.DataStreamTemplate(false, false))
                                                    .build(),
                                            "myhighertemplate",
                                            ComposableIndexTemplate.builder()
                                                    .indexPatterns(Collections.singletonList("myds"))
                                                    .dataStreamTemplate(new ComposableIndexTemplate.DataStreamTemplate(false, false))
                                                    .priority(1_000L)
                                                    .build()
                                    )
                            )
                    );
             mBuilder.put(DataStreamTestHelper.newInstance("myds", Collections.singletonList(mBuilder.get("myindex").getIndex())));

            // Test where policy exists and is used by an index, datastream, and template
            ClusterState state = ClusterState.builder(new ClusterName("mycluster")).metadata(mBuilder.build()).build();
            assertThat(
                    LifecyclePolicyUtils.calculateUsage(iner, state, "mypolicy"),
                    equalTo(
                            new ItemUsage(List.of("myindex"),List.of(), List.of("mytemplate"))
                    )
            );
        }
    }

In this test we see a data stream whose winner template does not have a policy but there is another candidate template with lower priority that has our policy. The optimisation wrongly reports that this policy is used by the data stream.

We can explore more ideas like this, for example:

  • What if we filter and sort only data stream templates? In the worst case scenario we make something slow even slower, but it might speed up some things in many other cases.

/** A map from policy name to list of data streams that use that policy. */
private final Map<String, List<String>> policyToDataStream;
/** A map from composable template name to the policy name it uses (or null) */
private final Map<String, String> templateToPolicy;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The templateToPolicy map is basically only there to save some MetadataIndexTemplateService.resolveSettings calls (as we have to loop over all templates in calculateUsage anyway), but resolving the settings seemed to be relatively expensive, so templateToPolicy basically serves as a cache for that.

List<String> indices = new ArrayList<>();
for (IndexMetadata indexMetadata : state.metadata().indices().values()) {
if (policyName.equals(indexMetadata.getLifecyclePolicyName())) {
indices.add(indexMetadata.getIndex().getName());
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could pre-compute a map of policy to indices as well, but I'm not sure the memory vs. speed trade-off is worth it there.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On second thought, since we have to build this list for every policy anyway, pre-computing these lists and putting them in a map shouldn't be too much additional memory overhead. I'll wait till someone has done a first review before making more changes (in case my whole approach is off).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And the same goes for the composable templates of course.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's think about this. You only need to keep a cache of what is necessary. Right?

For example:
Composable templates:
Cache the resolved templates that match our policies and their index patterns: template -> policy, probably you can also store policy -> templates.

Data streams
Go over the data streams like you already do, find the template for each data stream and check the cache template -> policy. You do not need to keep track of nulls anymore, since you have collected the relevant templates, if the template is not in your cache then the data stream shouldn't be either. So you create the policy -> data stream.

Indices
I do not see an issue with going over the indices during initialisation because from what I saw the information is pre-calculated and then serialised. So, here you create policy -> indices.

Then retrieving the data is just picking them up from the cache. Thoughts?

@@ -1050,29 +1051,24 @@ static Set<String> dataStreamsExclusivelyUsingTemplates(final ClusterState state
.reduce(Sets::union)
.orElse(Set.of());

// Filter and sort all composable templates in advance, to speed up template retrieval later on.
var templates = state.metadata()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this confused me a little bit, both the name and the comment. It wasn't clear to me what is filtered and when we use the word templates, which templates do we mean. So, what if we rename:

  • templateNames to requestedTemplateNames, signalling that these was provided by the caller.
  • templates to restTemplateCandidates or otherTemplateCandidates, signalling that these are the rest, so not the ones provided by the user and candidates because isGlobalAndHasIndexHiddenSetting is filtering out the non candidates.

Names are up for debate but I hope I made clear the direction I am considering.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I've renamed the templates variable and updated the comments. Let me know if this is more clear.

final List<Tuple<String, ComposableIndexTemplate>> candidates = new ArrayList<>();
for (Map.Entry<String, ComposableIndexTemplate> entry : metadata.templatesV2().entrySet()) {
outerLoop:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm..... I am not going to lie, seeing this does not give me a good feeling. Is this really the best alternative to structure the code?

Copy link
Contributor Author

@nielsbauman nielsbauman May 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I've found a much better alternative, see 293ff9d. Let me know whether you agree that this approach is better.

@nielsbauman nielsbauman requested a review from gmarouli May 21, 2024 14:41
Copy link
Contributor

@gmarouli gmarouli left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies for the late review! Really happy to see this work @nielsbauman, this is awesome! Let me know what you think on the comments.

I like the direction that you have taken :)

Comment on lines +1276 to +1288
public static String findV2Template(
Metadata metadata,
Collection<Map.Entry<String, ComposableIndexTemplate>> templates,
String indexName,
boolean isHidden,
boolean exitOnFirstMatch
) {
final List<Tuple<String, ComposableIndexTemplate>> candidates = findV2CandidateTemplates(
templates,
indexName,
isHidden,
exitOnFirstMatch
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you like the idea of more explicit method names like I suggested in the previous comment. We could change the visibility of this method to private or package private depending on the testing requirements. This way this method won't be misused by setting exitOnFirstMatch with an unordered template list.

What do you think?

if (matched) {
candidates.add(Tuple.tuple(name, template));
if (isHidden) {
final boolean hasMatchAllTemplate = template.indexPatterns().stream().anyMatch(Regex::isMatchAllPattern);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: hasMatchAllTemplate I think this reads better as isMatchAllTemplate because it talks about the template itself. Otherwise it should be hasMatchAllPattern.

Comment on lines 1051 to 1052
.reduce(Sets::union)
.orElse(Set.of());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I know this is not your code but since it's in my visual I wanted to add a small note.

I think this code is a bit more complex than it has to be.

.map(Set::copyOf)
.reduce(Sets::union)
.orElse(Set.of())

Effectively we want to add all patterns into a set, right? I think the following code is a bit more readable.

.flatMap(List::stream)
.collect(Collectors.toSet())

What do you think?

Comment on lines 1314 to 1317
* Return an ordered list of the name (id) and composable index templates that would apply to an index. The first
* one is the winner template that is applied to this index. In the event that no templates are matched,
* an empty list is returned.
* an empty list is returned. If <code>exitOnFirstMatch</code> is true, we return immediately after finding a match.
*/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we need to elaborate a bit more on how to use this method. What are the trade-offs of exitOnFirstMatch. How it should be used etc.

if (indexTemplate == null) {
continue;
}
Settings settings = MetadataIndexTemplateService.resolveSettings(state.metadata(), indexTemplate);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before doing this, shouldn't we check if the template is already in templateToPolicy, then we do not have to resolve the settings again?

.build();
assertThat(
new LifecyclePolicyUsageCalculator(iner, state, List.of("mypolicy")).calculateUsage("mypolicy"),
equalTo(new ItemUsage(Collections.emptyList(), Collections.emptyList(), Collections.emptyList()))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
equalTo(new ItemUsage(Collections.emptyList(), Collections.emptyList(), Collections.emptyList()))
equalTo(new ItemUsage(List.of(), List.of(), List.of()))

.build();
assertThat(
new LifecyclePolicyUsageCalculator(iner, state, List.of("mypolicy")).calculateUsage("mypolicy"),
equalTo(new ItemUsage(Collections.singleton("myindex"), Collections.emptyList(), Collections.emptyList()))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
equalTo(new ItemUsage(Collections.singleton("myindex"), Collections.emptyList(), Collections.emptyList()))
equalTo(new ItemUsage(List.of("myindex"), List.of(), List.of()))

public LifecyclePolicyUsageCalculator(
final IndexNameExpressionResolver indexNameExpressionResolver,
final ClusterState state,
List<String> names
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because we have a lot of elements involved here, I think it would be easier to specify what are these names. I think policyNames or policies is a bit more informative.

What do you think?

Comment on lines 1267 to +1269
public static String findV2Template(Metadata metadata, String indexName, boolean isHidden) {
final List<Tuple<String, ComposableIndexTemplate>> candidates = findV2CandidateTemplates(metadata, indexName, isHidden);
return findV2Template(metadata, metadata.templatesV2().entrySet(), indexName, isHidden, false);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if we create one more method called: findV2TemplateFromSortedList which will call findV2Template(metadata, metadata.templatesV2().entrySet(), indexName, isHidden, true) and we can document that if you want to retrieve the templates of multiple targets you can provide a sorted list of templates which will speed things up.

I think this will clarify a bit more which method to use and it will make explicit that the templates need to be ordered based on priority.

List<String> indices = new ArrayList<>();
for (IndexMetadata indexMetadata : state.metadata().indices().values()) {
if (policyName.equals(indexMetadata.getLifecyclePolicyName())) {
indices.add(indexMetadata.getIndex().getName());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's think about this. You only need to keep a cache of what is necessary. Right?

For example:
Composable templates:
Cache the resolved templates that match our policies and their index patterns: template -> policy, probably you can also store policy -> templates.

Data streams
Go over the data streams like you already do, find the template for each data stream and check the cache template -> policy. You do not need to keep track of nulls anymore, since you have collected the relevant templates, if the template is not in your cache then the data stream shouldn't be either. So you create the policy -> data stream.

Indices
I do not see an issue with going over the indices during initialisation because from what I saw the information is pre-calculated and then serialised. So, here you create policy -> indices.

Then retrieving the data is just picking them up from the cache. Thoughts?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
:Data Management/ILM+SLM Index and Snapshot lifecycle management >enhancement Team:Data Management Meta label for data/management team v9.0.0
Projects
None yet
Development

Successfully merging this pull request may close these issues.

ILM policies retrieval API is taking a long time in a large cluster
5 participants