Skip to content

Commit 6dfddc4

Browse files
authored
ILM Explain: valid JSON on truncated step info (#137638) (#138605)
1 parent e96719d commit 6dfddc4

File tree

6 files changed

+172
-29
lines changed

6 files changed

+172
-29
lines changed

docs/changelog/137638.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
pr: 137638
2+
summary: "ILM Explain: valid JSON on truncated step info"
3+
area: ILM+SLM
4+
type: bug
5+
issues:
6+
- 135458

server/src/main/java/org/elasticsearch/cluster/metadata/LifecycleExecutionState.java

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -287,15 +287,31 @@ public Map<String, String> asMap() {
287287
return Collections.unmodifiableMap(result);
288288
}
289289

290-
public static String truncateWithExplanation(String input) {
291-
if (input != null && input.length() > MAXIMUM_STEP_INFO_STRING_LENGTH) {
292-
return Strings.cleanTruncate(input, MAXIMUM_STEP_INFO_STRING_LENGTH)
293-
+ "... ("
294-
+ (input.length() - MAXIMUM_STEP_INFO_STRING_LENGTH)
295-
+ " chars truncated)";
296-
} else {
297-
return input;
298-
}
290+
/**
291+
* Truncates a potentially long JSON string to ensure it does not exceed {@link #MAXIMUM_STEP_INFO_STRING_LENGTH}. If truncation
292+
* occurs, an explanation suffix is appended to the truncated string indicating <i>approximately</i> how many characters were removed.
293+
* We return an approximation because we're valuing code simplicity over accuracy in this area.
294+
*
295+
* @param json the JSON string to potentially truncate
296+
* @return the original JSON string if its length is within the limit, otherwise a truncated version with an explanation suffix - in
297+
* correct JSON format
298+
*/
299+
public static String potentiallyTruncateLongJsonWithExplanation(String json) {
300+
if (json == null || json.length() <= MAXIMUM_STEP_INFO_STRING_LENGTH) {
301+
return json;
302+
}
303+
// we'll now do our best to truncate the JSON and have the result be valid JSON.
304+
// a long input JSON should generally only be possible with an exception turned into JSON that's well-formatted.
305+
// if we fail to produce valid JSON, we might return invalid JSON in REST API in niche cases, so this isn't catastrophic.
306+
assert json.startsWith("{\"") && json.endsWith("\"}") : "expected more specific JSON format, might produce invalid JSON";
307+
final int roughNumberOfCharsTruncated = json.length() - MAXIMUM_STEP_INFO_STRING_LENGTH;
308+
final String truncationExplanation = "... (~" + roughNumberOfCharsTruncated + " chars truncated)\"}";
309+
// To ensure that the resulting string is always <= the max, we also need to remove the length of our suffix.
310+
// This means that the actual number of characters removed is `truncationExplanation.length()` more than we say it is.
311+
final String truncated = Strings.cleanTruncate(json, MAXIMUM_STEP_INFO_STRING_LENGTH - truncationExplanation.length())
312+
+ truncationExplanation;
313+
assert truncated.length() <= MAXIMUM_STEP_INFO_STRING_LENGTH : "truncation didn't work";
314+
return truncated;
299315
}
300316

301317
public static class Builder {
@@ -340,7 +356,7 @@ public Builder setFailedStep(String failedStep) {
340356
}
341357

342358
public Builder setStepInfo(String stepInfo) {
343-
this.stepInfo = truncateWithExplanation(stepInfo);
359+
this.stepInfo = potentiallyTruncateLongJsonWithExplanation(stepInfo);
344360
return this;
345361
}
346362

x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/ilm/LifecycleExecutionStateTests.java

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,18 @@
88
package org.elasticsearch.xpack.core.ilm;
99

1010
import org.elasticsearch.cluster.metadata.LifecycleExecutionState;
11+
import org.elasticsearch.common.Strings;
1112
import org.elasticsearch.test.ESTestCase;
1213
import org.elasticsearch.test.EqualsHashCodeTestUtils;
1314

1415
import java.util.HashMap;
1516
import java.util.Map;
1617

18+
import static org.hamcrest.Matchers.endsWith;
1719
import static org.hamcrest.Matchers.equalTo;
20+
import static org.hamcrest.Matchers.hasLength;
21+
import static org.hamcrest.Matchers.not;
22+
import static org.hamcrest.Matchers.sameInstance;
1823

1924
public class LifecycleExecutionStateTests extends ESTestCase {
2025

@@ -28,14 +33,12 @@ public void testTruncatingStepInfo() {
2833
Map<String, String> custom = createCustomMetadata();
2934
LifecycleExecutionState state = LifecycleExecutionState.fromCustomMetadata(custom);
3035
assertThat(custom.get("step_info"), equalTo(state.stepInfo()));
31-
String longStepInfo = randomAlphanumericOfLength(LifecycleExecutionState.MAXIMUM_STEP_INFO_STRING_LENGTH + 100);
36+
String longStepInfo = "{\"key\": \""
37+
+ randomAlphanumericOfLength(LifecycleExecutionState.MAXIMUM_STEP_INFO_STRING_LENGTH + 100)
38+
+ "\"}";
3239
LifecycleExecutionState newState = LifecycleExecutionState.builder(state).setStepInfo(longStepInfo).build();
33-
// Length includes the post suffix
34-
assertThat(newState.stepInfo().length(), equalTo(LifecycleExecutionState.MAXIMUM_STEP_INFO_STRING_LENGTH + 25));
35-
assertThat(
36-
newState.stepInfo().substring(LifecycleExecutionState.MAXIMUM_STEP_INFO_STRING_LENGTH, 1049),
37-
equalTo("... (100 chars truncated)")
38-
);
40+
assertThat(newState.stepInfo(), hasLength(LifecycleExecutionState.MAXIMUM_STEP_INFO_STRING_LENGTH));
41+
assertThat(newState.stepInfo(), endsWith("... (~111 chars truncated)\"}"));
3942
}
4043

4144
public void testEmptyValuesAreNotSerialized() {
@@ -145,6 +148,59 @@ public void testGetCurrentStepKey() {
145148
assertNull(error6.getMessage());
146149
}
147150

151+
/** test that strings with length less than or equal to maximum string length are not truncated and returned as-is */
152+
public void testPotentiallyTruncateLongJsonWithExplanationNoNeedToTruncate() {
153+
final String input = randomAlphaOfLengthBetween(0, LifecycleExecutionState.MAXIMUM_STEP_INFO_STRING_LENGTH);
154+
assertSame(input, LifecycleExecutionState.potentiallyTruncateLongJsonWithExplanation(input));
155+
}
156+
157+
/** test that string with length one character over the max limit has truncation applied to it and has correct explanation */
158+
public void testPotentiallyTruncateLongJsonWithExplanationOneCharTruncated() {
159+
final String jsonBaseFormat = "{\"key\": \"%s\"}";
160+
final int baseLength = Strings.format(jsonBaseFormat, "").length();
161+
final String value = randomAlphanumericOfLength(LifecycleExecutionState.MAXIMUM_STEP_INFO_STRING_LENGTH - baseLength + 1);
162+
final String input = Strings.format(jsonBaseFormat, value);
163+
164+
final String expectedSuffix = "... (~1 chars truncated)";
165+
final String expectedOutput = Strings.format(
166+
jsonBaseFormat,
167+
value.substring(0, value.length() - expectedSuffix.length() - 1) + expectedSuffix
168+
);
169+
final String actualOutput = LifecycleExecutionState.potentiallyTruncateLongJsonWithExplanation(input);
170+
assertThat(actualOutput, hasLength(LifecycleExecutionState.MAXIMUM_STEP_INFO_STRING_LENGTH));
171+
assertEquals(expectedOutput, actualOutput);
172+
}
173+
174+
/** test that string with length two characters over the max limit has truncation applied to it and has correct explanation */
175+
public void testPotentiallyTruncateLongJsonWithExplanationTwoCharsTruncated() {
176+
final String jsonBaseFormat = "{\"key\": \"%s\"}";
177+
final int baseLength = Strings.format(jsonBaseFormat, "").length();
178+
final String value = randomAlphanumericOfLength(LifecycleExecutionState.MAXIMUM_STEP_INFO_STRING_LENGTH - baseLength + 2);
179+
final String input = Strings.format(jsonBaseFormat, value);
180+
181+
final String expectedSuffix = "... (~2 chars truncated)";
182+
final String expectedOutput = Strings.format(
183+
jsonBaseFormat,
184+
value.substring(0, value.length() - expectedSuffix.length() - 2) + expectedSuffix
185+
);
186+
final String actualOutput = LifecycleExecutionState.potentiallyTruncateLongJsonWithExplanation(input);
187+
assertThat(actualOutput, hasLength(LifecycleExecutionState.MAXIMUM_STEP_INFO_STRING_LENGTH));
188+
assertEquals(expectedOutput, actualOutput);
189+
}
190+
191+
public void testPotentiallyTruncateLongJsonWithExplanationIsIdempotent() {
192+
final String input = "{\"key\": \"" + randomAlphanumericOfLength(LifecycleExecutionState.MAXIMUM_STEP_INFO_STRING_LENGTH) + "\"}";
193+
194+
final String firstOutput = LifecycleExecutionState.potentiallyTruncateLongJsonWithExplanation(input);
195+
196+
assertThat(firstOutput, hasLength(LifecycleExecutionState.MAXIMUM_STEP_INFO_STRING_LENGTH));
197+
assertThat(firstOutput, not(equalTo(input)));
198+
199+
final String secondOutput = LifecycleExecutionState.potentiallyTruncateLongJsonWithExplanation(firstOutput);
200+
201+
assertThat(secondOutput, sameInstance(firstOutput));
202+
}
203+
148204
private LifecycleExecutionState mutate(LifecycleExecutionState toMutate) {
149205
LifecycleExecutionState.Builder newState = LifecycleExecutionState.builder(toMutate);
150206
switch (randomIntBetween(0, 18)) {

x-pack/plugin/ilm/qa/multi-node/src/javaRestTest/java/org/elasticsearch/xpack/ilm/ExplainLifecycleIT.java

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,10 @@
1010
import org.apache.http.entity.ContentType;
1111
import org.apache.http.entity.StringEntity;
1212
import org.apache.http.util.EntityUtils;
13-
import org.apache.logging.log4j.LogManager;
14-
import org.apache.logging.log4j.Logger;
1513
import org.elasticsearch.client.Request;
1614
import org.elasticsearch.client.Response;
1715
import org.elasticsearch.cluster.metadata.IndexMetadata;
16+
import org.elasticsearch.cluster.metadata.LifecycleExecutionState;
1817
import org.elasticsearch.common.Strings;
1918
import org.elasticsearch.common.settings.Settings;
2019
import org.elasticsearch.core.TimeValue;
@@ -44,6 +43,7 @@
4443
import static org.elasticsearch.xpack.TimeSeriesRestDriver.explainIndex;
4544
import static org.hamcrest.Matchers.allOf;
4645
import static org.hamcrest.Matchers.containsString;
46+
import static org.hamcrest.Matchers.equalTo;
4747
import static org.hamcrest.Matchers.greaterThan;
4848
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
4949
import static org.hamcrest.Matchers.hasKey;
@@ -53,7 +53,6 @@
5353
import static org.hamcrest.Matchers.nullValue;
5454

5555
public class ExplainLifecycleIT extends IlmESRestTestCase {
56-
private static final Logger logger = LogManager.getLogger(ExplainLifecycleIT.class);
5756
private static final String FAILED_STEP_RETRY_COUNT_FIELD = "failed_step_retry_count";
5857
private static final String IS_AUTO_RETRYABLE_ERROR_FIELD = "is_auto_retryable_error";
5958

@@ -321,6 +320,78 @@ public void testStepInfoPreservedOnAutoRetry() throws Exception {
321320
}, 30, TimeUnit.SECONDS);
322321
}
323322

323+
/**
324+
* Test that, when there is an ILM <code>previous_step_info</code> that has had truncation applied to it (due to it being too long),
325+
* the truncation:
326+
* <ul>
327+
* <li>doesn't break the JSON returned in <code>/{index}/_ilm/explain</code></li>
328+
* <li>truncates as expected</li>
329+
* </ul>
330+
* We test this by creating an ILM policy that rolls-over at 1 document, and an index pattern that has a non-existing rollover alias
331+
* that has a very long name (to trip the truncation due to the rollover alias name being in the <code>previous_step_info</code>).
332+
* <p>
333+
* We then index a document, wait for attempted rollover, and assert that we get valid JSON and expected truncated message
334+
* in <code>/{index}/_ilm/explain</code>.
335+
*/
336+
public void testTruncatedPreviousStepInfoDoesNotBreakExplainJson() throws Exception {
337+
final String policyName = "policy-" + randomAlphaOfLength(5).toLowerCase(Locale.ROOT);
338+
339+
final Request createPolicy = new Request("PUT", "_ilm/policy/" + policyName);
340+
createPolicy.setJsonEntity("""
341+
{
342+
"policy": {
343+
"phases": {
344+
"hot": {
345+
"actions": {
346+
"rollover": {
347+
"max_docs": 1
348+
}
349+
}
350+
}
351+
}
352+
}
353+
}
354+
""");
355+
assertOK(client().performRequest(createPolicy));
356+
357+
final String indexBase = "my-logs";
358+
final String indexName = indexBase + "-" + randomAlphaOfLength(5).toLowerCase(Locale.ROOT);
359+
final String longMissingAliasName = randomAlphanumericOfLength(LifecycleExecutionState.MAXIMUM_STEP_INFO_STRING_LENGTH);
360+
361+
final Request templateRequest = new Request("PUT", "_index_template/template_" + policyName);
362+
final String templateBody = Strings.format("""
363+
{
364+
"index_patterns": ["%s-*"],
365+
"template": {
366+
"settings": {
367+
"index.lifecycle.name": "%s",
368+
"index.lifecycle.rollover_alias": "%s"
369+
}
370+
}
371+
}
372+
""", indexBase, policyName, longMissingAliasName);
373+
templateRequest.setJsonEntity(templateBody);
374+
375+
assertOK(client().performRequest(templateRequest));
376+
377+
final Request indexRequest = new Request("POST", "/" + indexName + "/_doc/1");
378+
indexRequest.setJsonEntity("{\"test\":\"value\"}");
379+
assertOK(client().performRequest(indexRequest));
380+
381+
final String expectedReason = Strings.format(
382+
"index.lifecycle.rollover_alias [%s... (~122 chars truncated)",
383+
longMissingAliasName.substring(0, longMissingAliasName.length() - 107)
384+
);
385+
final Map<String, Object> expectedStepInfo = Map.of("type", "illegal_argument_exception", "reason", expectedReason);
386+
assertBusy(() -> {
387+
final Map<String, Object> explainIndex = explainIndex(client(), indexName);
388+
389+
final String assertionMessage = "Assertion failed for the following response: " + explainIndex;
390+
final Object previousStepInfo = explainIndex.get("previous_step_info");
391+
assertThat(assertionMessage, previousStepInfo, equalTo(expectedStepInfo));
392+
}, 30, TimeUnit.SECONDS);
393+
}
394+
324395
private void assertUnmanagedIndex(Map<String, Object> explainIndexMap) {
325396
assertThat(explainIndexMap.get("managed"), is(false));
326397
assertThat(explainIndexMap.get("time_since_index_creation"), is(nullValue()));

x-pack/plugin/ilm/src/main/java/org/elasticsearch/xpack/ilm/history/ILMHistoryItem.java

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,7 @@ public static ILMHistoryItem failure(
8787
) {
8888
Objects.requireNonNull(error, "ILM failures require an attached exception");
8989
String fullErrorString = exceptionToString(error);
90-
String truncatedErrorString = LifecycleExecutionState.truncateWithExplanation(fullErrorString);
91-
if (truncatedErrorString.equals(fullErrorString) == false) {
92-
// Append a closing quote and closing brace to attempt to make it valid JSON.
93-
// There is no requirement that it actually be valid JSON, so this is
94-
// best-effort, but does not cause problems if it is still invalid.
95-
truncatedErrorString += "\"}";
96-
}
90+
String truncatedErrorString = LifecycleExecutionState.potentiallyTruncateLongJsonWithExplanation(fullErrorString);
9791
return new ILMHistoryItem(index, policyId, timestamp, indexAge, false, executionState, truncatedErrorString);
9892
}
9993

x-pack/plugin/ilm/src/test/java/org/elasticsearch/xpack/ilm/history/ILMHistoryItemTests.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,10 @@ public void testTruncateLongError() throws IOException {
142142
"{\"type\":\"illegal_argument_exception\",\"reason\":\""
143143
// We subtract a number of characters here due to the truncation being based
144144
// on the length of the whole string, not just the "reason" part.
145-
+ longError.substring(0, LifecycleExecutionState.MAXIMUM_STEP_INFO_STRING_LENGTH - 47)
145+
+ longError.substring(0, LifecycleExecutionState.MAXIMUM_STEP_INFO_STRING_LENGTH - 76)
146146
)
147147
);
148-
assertThat((String) item.get("error_details"), matchesPattern(".*\\.\\.\\. \\(\\d+ chars truncated\\).*"));
148+
assertThat((String) item.get("error_details"), matchesPattern(".*\\.\\.\\. \\(~\\d+ chars truncated\\).*"));
149149
}
150150
}
151151
}

0 commit comments

Comments
 (0)