Skip to content

Commit 431ca2d

Browse files
DaveCTurnergeorgewallace
authored andcommitted
Introduce BoundedDelimitedStringCollector (elastic#124303)
An issue with `Strings#collectionToDelimitedStringWithLimit` is that you need to collect all the items together up front first, even if you're going to throw most of them away. This commit introduces `BoundedDelimitedStringCollector` which allows to accumulate the items one-at-a-time instead.
1 parent 69fadc7 commit 431ca2d

File tree

6 files changed

+211
-123
lines changed

6 files changed

+211
-123
lines changed

server/src/main/java/org/elasticsearch/action/admin/indices/rollover/LazyRolloverAction.java

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,8 @@ record LazyRolloverExecutor(
188188
@Override
189189
public ClusterState execute(BatchExecutionContext<LazyRolloverTask> batchExecutionContext) {
190190
final var listener = new AllocationActionMultiListener<RolloverResponse>(threadPool.getThreadContext());
191-
final var results = new ArrayList<String>(batchExecutionContext.taskContexts().size());
191+
var reasonBuilder = new StringBuilder("lazy bulk rollover [");
192+
final var resultsCollector = new Strings.BoundedDelimitedStringCollector(reasonBuilder, ",", 1024);
192193
var state = batchExecutionContext.initialState();
193194
Map<ProjectId, Map<RolloverRequest, List<TaskContext<LazyRolloverTask>>>> groupedRequests = new HashMap<>();
194195
for (final var taskContext : batchExecutionContext.taskContexts()) {
@@ -202,7 +203,7 @@ public ClusterState execute(BatchExecutionContext<LazyRolloverTask> batchExecuti
202203
try {
203204
RolloverRequest rolloverRequest = entry.getKey();
204205
final var projectState = state.projectState(projectRequests.getKey());
205-
state = executeTask(projectState, rolloverRequest, results, rolloverTaskContexts, listener);
206+
state = executeTask(projectState, rolloverRequest, resultsCollector::appendItem, rolloverTaskContexts, listener);
206207
} catch (Exception e) {
207208
rolloverTaskContexts.forEach(taskContext -> taskContext.onFailure(e));
208209
} finally {
@@ -212,11 +213,10 @@ public ClusterState execute(BatchExecutionContext<LazyRolloverTask> batchExecuti
212213
}
213214

214215
if (state != batchExecutionContext.initialState()) {
215-
var reason = new StringBuilder("lazy bulk rollover [");
216-
Strings.collectionToDelimitedStringWithLimit(results, ",", 1024, reason);
217-
reason.append(']');
216+
resultsCollector.finish();
217+
reasonBuilder.append(']');
218218
try (var ignored = batchExecutionContext.dropHeadersContext()) {
219-
state = allocationService.reroute(state, reason.toString(), listener.reroute());
219+
state = allocationService.reroute(state, reasonBuilder.toString(), listener.reroute());
220220
}
221221
} else {
222222
listener.noRerouteNeeded();
@@ -227,7 +227,7 @@ public ClusterState execute(BatchExecutionContext<LazyRolloverTask> batchExecuti
227227
public ClusterState executeTask(
228228
ProjectState currentState,
229229
RolloverRequest rolloverRequest,
230-
ArrayList<String> results,
230+
Consumer<String> results,
231231
List<TaskContext<LazyRolloverTask>> rolloverTaskContexts,
232232
AllocationActionMultiListener<RolloverResponse> allocationActionMultiListener
233233
) throws Exception {
@@ -264,7 +264,7 @@ public ClusterState executeTask(
264264
null,
265265
isFailureStoreRollover
266266
);
267-
results.add(rolloverResult.sourceIndexName() + "->" + rolloverResult.rolloverIndexName());
267+
results.accept(rolloverResult.sourceIndexName() + "->" + rolloverResult.rolloverIndexName());
268268
logger.trace("lazy rollover result [{}]", rolloverResult);
269269

270270
final var rolloverIndexName = rolloverResult.rolloverIndexName();

server/src/main/java/org/elasticsearch/action/admin/indices/rollover/TransportRolloverAction.java

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,14 @@
6060
import org.elasticsearch.transport.TransportService;
6161

6262
import java.time.Instant;
63-
import java.util.ArrayList;
6463
import java.util.Arrays;
6564
import java.util.Collection;
6665
import java.util.HashMap;
6766
import java.util.List;
6867
import java.util.Map;
6968
import java.util.Objects;
7069
import java.util.Optional;
70+
import java.util.function.Consumer;
7171
import java.util.stream.Collectors;
7272

7373
/**
@@ -517,22 +517,22 @@ record RolloverExecutor(
517517
@Override
518518
public ClusterState execute(BatchExecutionContext<RolloverTask> batchExecutionContext) {
519519
final var listener = new AllocationActionMultiListener<RolloverResponse>(threadPool.getThreadContext());
520-
final var results = new ArrayList<String>(batchExecutionContext.taskContexts().size());
520+
final var reasonBuilder = new StringBuilder("bulk rollover [");
521+
final var resultsCollector = new Strings.BoundedDelimitedStringCollector(reasonBuilder, ",", 1024);
521522
var state = batchExecutionContext.initialState();
522523
for (final var taskContext : batchExecutionContext.taskContexts()) {
523524
try (var ignored = taskContext.captureResponseHeaders()) {
524-
state = executeTask(state, results, taskContext, listener);
525+
state = executeTask(state, resultsCollector::appendItem, taskContext, listener);
525526
} catch (Exception e) {
526527
taskContext.onFailure(e);
527528
}
528529
}
529530

530531
if (state != batchExecutionContext.initialState()) {
531-
var reason = new StringBuilder("bulk rollover [");
532-
Strings.collectionToDelimitedStringWithLimit(results, ",", 1024, reason);
533-
reason.append(']');
532+
resultsCollector.finish();
533+
reasonBuilder.append(']');
534534
try (var ignored = batchExecutionContext.dropHeadersContext()) {
535-
state = allocationService.reroute(state, reason.toString(), listener.reroute());
535+
state = allocationService.reroute(state, reasonBuilder.toString(), listener.reroute());
536536
}
537537
} else {
538538
listener.noRerouteNeeded();
@@ -542,7 +542,7 @@ public ClusterState execute(BatchExecutionContext<RolloverTask> batchExecutionCo
542542

543543
public ClusterState executeTask(
544544
ClusterState currentState,
545-
ArrayList<String> results,
545+
Consumer<String> resultsCollector,
546546
TaskContext<RolloverTask> rolloverTaskContext,
547547
AllocationActionMultiListener<RolloverResponse> allocationActionMultiListener
548548
) throws Exception {
@@ -613,7 +613,7 @@ public ClusterState executeTask(
613613
rolloverTask.autoShardingResult(),
614614
targetFailureStore
615615
);
616-
results.add(rolloverResult.sourceIndexName() + "->" + rolloverResult.rolloverIndexName());
616+
resultsCollector.accept(rolloverResult.sourceIndexName() + "->" + rolloverResult.rolloverIndexName());
617617
logger.trace("rollover result [{}]", rolloverResult);
618618

619619
final var rolloverIndexName = rolloverResult.rolloverIndexName();

server/src/main/java/org/elasticsearch/common/Strings.java

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -575,22 +575,55 @@ public static void collectionToDelimitedString(Iterable<?> coll, String delimite
575575
* items are omitted
576576
*/
577577
public static void collectionToDelimitedStringWithLimit(Iterable<?> coll, String delimiter, int appendLimit, StringBuilder sb) {
578-
final Iterator<?> it = coll.iterator();
579-
final long lengthLimit = sb.length() + appendLimit; // long to avoid overflow
580-
int count = 0;
581-
while (it.hasNext()) {
582-
sb.append(it.next());
578+
final var boundedDelimitedStringCollector = new BoundedDelimitedStringCollector(sb, delimiter, appendLimit);
579+
coll.forEach(boundedDelimitedStringCollector::appendItem);
580+
boundedDelimitedStringCollector.finish();
581+
}
582+
583+
/**
584+
* Collects a sequence of objects into a delimited string, dropping objects once the string reaches a certain maximum length. Similar to
585+
* {@link #collectionToDelimitedStringWithLimit} except that this doesn't need the collection of items to be provided up front.
586+
*/
587+
public static final class BoundedDelimitedStringCollector {
588+
private final StringBuilder stringBuilder;
589+
private final String delimiter;
590+
private final long lengthLimit;
591+
private int count = 0;
592+
private int omitted = 0;
593+
594+
public BoundedDelimitedStringCollector(StringBuilder stringBuilder, String delimiter, int appendLimit) {
595+
this.stringBuilder = stringBuilder;
596+
this.delimiter = delimiter;
597+
this.lengthLimit = stringBuilder.length() + appendLimit; // long to avoid overflow
598+
}
599+
600+
/**
601+
* Add the given item's string representation to the string, with a delimiter if necessary and surrounded by the given prefix and
602+
* suffix, as long as the string is not already too long.
603+
*/
604+
public void appendItem(Object item) {
583605
count += 1;
584-
if (it.hasNext()) {
585-
sb.append(delimiter);
586-
if (sb.length() > lengthLimit) {
587-
int omitted = 0;
588-
while (it.hasNext()) {
589-
it.next();
590-
omitted += 1;
591-
}
592-
sb.append("... (").append(count + omitted).append(" in total, ").append(omitted).append(" omitted)");
593-
}
606+
if (omitted > 0) {
607+
omitted += 1;
608+
return;
609+
}
610+
if (count > 1) {
611+
stringBuilder.append(delimiter);
612+
}
613+
if (stringBuilder.length() > lengthLimit) {
614+
omitted += 1;
615+
stringBuilder.append("..."); // indicate there are some omissions, just in case the caller forgets to call finish()
616+
return;
617+
}
618+
stringBuilder.append(item);
619+
}
620+
621+
/**
622+
* Complete the collection, adding to the string a summary of omitted objects, if any.
623+
*/
624+
public void finish() {
625+
if (omitted > 0) {
626+
stringBuilder.append(" (").append(count).append(" in total, ").append(omitted).append(" omitted)");
594627
}
595628
}
596629
}

server/src/main/java/org/elasticsearch/snapshots/SnapshotsService.java

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2506,13 +2506,10 @@ public void onFailure(Exception e) {
25062506
@Override
25072507
public void onFailure(Exception e) {
25082508
logger.warn(() -> {
2509-
final StringBuilder sb = new StringBuilder("failed to complete snapshot deletion for [");
2510-
Strings.collectionToDelimitedStringWithLimit(
2511-
deleteEntry.snapshots().stream().map(SnapshotId::getName).toList(),
2512-
",",
2513-
1024,
2514-
sb
2515-
);
2509+
final var sb = new StringBuilder("failed to complete snapshot deletion for [");
2510+
final var collector = new Strings.BoundedDelimitedStringCollector(sb, ",", 1024);
2511+
deleteEntry.snapshots().forEach(s -> collector.appendItem(s.getName()));
2512+
collector.finish();
25162513
sb.append("] from repository [").append(deleteEntry.repository()).append("]");
25172514
return sb;
25182515
}, e);
@@ -2527,7 +2524,14 @@ protected void handleListeners(List<ActionListener<Void>> deleteListeners) {
25272524
);
25282525
}
25292526
}, () -> {
2530-
logger.info("snapshots {} deleted", snapshotIds);
2527+
logger.info(() -> {
2528+
final var sb = new StringBuilder("snapshots [");
2529+
final var collector = new Strings.BoundedDelimitedStringCollector(sb, ",", 1024);
2530+
snapshotIds.forEach(collector::appendItem);
2531+
collector.finish();
2532+
sb.append("] deleted");
2533+
return sb;
2534+
});
25312535
doneFuture.onResponse(null);
25322536
});
25332537
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
package org.elasticsearch.common;
11+
12+
import com.carrotsearch.randomizedtesting.annotations.Name;
13+
import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
14+
15+
import org.elasticsearch.test.ESTestCase;
16+
17+
import java.util.ArrayList;
18+
import java.util.List;
19+
import java.util.stream.Stream;
20+
21+
import static org.elasticsearch.common.Strings.collectionToDelimitedString;
22+
import static org.hamcrest.Matchers.allOf;
23+
import static org.hamcrest.Matchers.containsString;
24+
import static org.hamcrest.Matchers.endsWith;
25+
import static org.hamcrest.Matchers.equalTo;
26+
import static org.hamcrest.Matchers.lessThanOrEqualTo;
27+
28+
public class BoundedDelimitedStringCollectorTests extends ESTestCase {
29+
30+
private interface TestHarness {
31+
String getResult(Iterable<?> collection, String delimiter, int appendLimit);
32+
33+
enum Type {
34+
COLLECTING,
35+
ITERATING
36+
}
37+
}
38+
39+
private final TestHarness testHarness;
40+
41+
@ParametersFactory
42+
public static Iterable<Object[]> parameters() throws Exception {
43+
return Stream.of(TestHarness.Type.values()).map(x -> new Object[] { x })::iterator;
44+
}
45+
46+
public BoundedDelimitedStringCollectorTests(@Name("type") TestHarness.Type testHarnessType) {
47+
testHarness = switch (testHarnessType) {
48+
case COLLECTING -> (collection, delimiter, appendLimit) -> {
49+
final var stringBuilder = new StringBuilder();
50+
final var collector = new Strings.BoundedDelimitedStringCollector(stringBuilder, delimiter, appendLimit);
51+
collection.forEach(collector::appendItem);
52+
collector.finish();
53+
return stringBuilder.toString();
54+
};
55+
case ITERATING -> (collection, delimiter, appendLimit) -> {
56+
final var stringBuilder = new StringBuilder();
57+
Strings.collectionToDelimitedStringWithLimit(collection, delimiter, appendLimit, stringBuilder);
58+
return stringBuilder.toString();
59+
};
60+
};
61+
}
62+
63+
public void testCollectionToDelimitedStringWithLimitZero() {
64+
final String delimiter = randomFrom("", ",", ", ", "/");
65+
66+
final int count = between(0, 100);
67+
final List<String> strings = new ArrayList<>(count);
68+
while (strings.size() < count) {
69+
// avoid starting with a sequence of empty appends, it makes the assertions much messier
70+
final int minLength = strings.isEmpty() && delimiter.isEmpty() ? 1 : 0;
71+
strings.add(randomAlphaOfLength(between(minLength, 10)));
72+
}
73+
74+
final String completelyTruncatedDescription = testHarness.getResult(strings, delimiter, 0);
75+
76+
if (count == 0) {
77+
assertThat(completelyTruncatedDescription, equalTo(""));
78+
} else if (count == 1) {
79+
assertThat(completelyTruncatedDescription, equalTo(strings.get(0)));
80+
} else {
81+
assertThat(
82+
completelyTruncatedDescription,
83+
equalTo(strings.get(0) + delimiter + "... (" + count + " in total, " + (count - 1) + " omitted)")
84+
);
85+
}
86+
}
87+
88+
public void testCollectionToDelimitedStringWithLimitTruncation() {
89+
final String delimiter = randomFrom("", ",", ", ", "/");
90+
91+
final int count = between(2, 100);
92+
final List<String> strings = new ArrayList<>(count);
93+
while (strings.size() < count) {
94+
// avoid empty appends, it makes the assertions much messier
95+
final int minLength = delimiter.isEmpty() ? 1 : 0;
96+
strings.add(randomAlphaOfLength(between(minLength, 10)));
97+
}
98+
99+
final int fullDescriptionLength = collectionToDelimitedString(strings, delimiter).length();
100+
final int lastItemSize = strings.get(count - 1).length();
101+
final int truncatedLength = between(0, fullDescriptionLength - lastItemSize - 1);
102+
final String truncatedDescription = testHarness.getResult(strings, delimiter, truncatedLength);
103+
104+
assertThat(truncatedDescription, allOf(containsString("... (" + count + " in total,"), endsWith(" omitted)")));
105+
106+
assertThat(
107+
truncatedDescription,
108+
truncatedDescription.length(),
109+
lessThanOrEqualTo(truncatedLength + ("0123456789" + delimiter + "... (999 in total, 999 omitted)").length())
110+
);
111+
}
112+
113+
public void testCollectionToDelimitedStringWithLimitNoTruncation() {
114+
final String delimiter = randomFrom("", ",", ", ", "/");
115+
116+
final int count = between(1, 100);
117+
final List<String> strings = new ArrayList<>(count);
118+
while (strings.size() < count) {
119+
strings.add(randomAlphaOfLength(between(0, 10)));
120+
}
121+
122+
final String fullDescription = collectionToDelimitedString(strings, delimiter);
123+
for (String string : strings) {
124+
assertThat(fullDescription, containsString(string));
125+
}
126+
127+
final int lastItemSize = strings.get(count - 1).length();
128+
final int minLimit = fullDescription.length() - lastItemSize;
129+
final int limit = randomFrom(between(minLimit, fullDescription.length()), between(minLimit, Integer.MAX_VALUE), Integer.MAX_VALUE);
130+
131+
assertThat(testHarness.getResult(strings, delimiter, limit), equalTo(fullDescription));
132+
}
133+
134+
}

0 commit comments

Comments
 (0)