From ba27fd1290ca1dfe4c65815725defcf6d3c603d6 Mon Sep 17 00:00:00 2001 From: Shivansh Arora Date: Thu, 9 May 2024 16:59:54 +0530 Subject: [PATCH] Add to and from XContent to ClusterBlock and ClusterBlocks Signed-off-by: Shivansh Arora --- .../cluster/block/ClusterBlock.java | 106 +++++++++++- .../cluster/block/ClusterBlockLevel.java | 8 + .../cluster/block/ClusterBlocks.java | 102 +++++++++++- .../cluster/block/ClusterBlockTests.java | 126 ++++++++++++++- .../cluster/block/ClusterBlocksTests.java | 151 ++++++++++++++++++ 5 files changed, 485 insertions(+), 8 deletions(-) create mode 100644 server/src/test/java/org/opensearch/cluster/block/ClusterBlocksTests.java diff --git a/server/src/main/java/org/opensearch/cluster/block/ClusterBlock.java b/server/src/main/java/org/opensearch/cluster/block/ClusterBlock.java index 5fa897c0b1185..c958d7752cdbb 100644 --- a/server/src/main/java/org/opensearch/cluster/block/ClusterBlock.java +++ b/server/src/main/java/org/opensearch/cluster/block/ClusterBlock.java @@ -32,19 +32,26 @@ package org.opensearch.cluster.block; +import org.opensearch.cluster.metadata.Metadata; import org.opensearch.common.Nullable; import org.opensearch.common.annotation.PublicApi; +import org.opensearch.common.util.set.Sets; import org.opensearch.core.common.io.stream.StreamInput; import org.opensearch.core.common.io.stream.StreamOutput; import org.opensearch.core.common.io.stream.Writeable; import org.opensearch.core.rest.RestStatus; import org.opensearch.core.xcontent.ToXContentFragment; import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; import java.io.IOException; import java.util.EnumSet; import java.util.Locale; import java.util.Objects; +import java.util.Set; + +import static org.opensearch.cluster.metadata.Metadata.CONTEXT_MODE_API; +import static org.opensearch.cluster.metadata.Metadata.CONTEXT_MODE_PARAM; /** * Blocks the cluster for concurrency @@ -54,6 +61,20 @@ @PublicApi(since = "1.0.0") public class ClusterBlock implements Writeable, ToXContentFragment { + static final String KEY_UUID = "uuid"; + static final String KEY_DESCRIPTION = "description"; + static final String KEY_RETRYABLE = "retryable"; + static final String KEY_DISABLE_STATE_PERSISTENCE = "disable_state_persistence"; + static final String KEY_LEVELS = "levels"; + static final String KEY_STATUS = "status"; + static final String KEY_ALLOW_RELEASE_RESOURCES = "allow_release_resources"; + private static final Set VALID_FIELDS = Sets.newHashSet( + KEY_UUID, + KEY_DESCRIPTION, + KEY_RETRYABLE, + KEY_DISABLE_STATE_PERSISTENCE, + KEY_LEVELS + ); private final int id; @Nullable private final String uuid; @@ -154,24 +175,99 @@ public boolean disableStatePersistence() { @Override public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + Metadata.XContentContext context = Metadata.XContentContext.valueOf(params.param(CONTEXT_MODE_PARAM, CONTEXT_MODE_API)); builder.startObject(Integer.toString(id)); if (uuid != null) { - builder.field("uuid", uuid); + builder.field(KEY_UUID, uuid); } - builder.field("description", description); - builder.field("retryable", retryable); + builder.field(KEY_DESCRIPTION, description); + builder.field(KEY_RETRYABLE, retryable); if (disableStatePersistence) { - builder.field("disable_state_persistence", disableStatePersistence); + builder.field(KEY_DISABLE_STATE_PERSISTENCE, disableStatePersistence); } - builder.startArray("levels"); + builder.startArray(KEY_LEVELS); for (ClusterBlockLevel level : levels) { builder.value(level.name().toLowerCase(Locale.ROOT)); } builder.endArray(); + if (context == Metadata.XContentContext.GATEWAY) { + builder.field(KEY_STATUS, status); + builder.field(KEY_ALLOW_RELEASE_RESOURCES, allowReleaseResources); + } builder.endObject(); return builder; } + public static ClusterBlock fromXContent(XContentParser parser, int id) throws IOException { + String uuid = null; + String description = null; + boolean retryable = false; + boolean disableStatePersistence = false; + RestStatus status = null; + boolean allowReleaseResources = false; + EnumSet levels = EnumSet.noneOf(ClusterBlockLevel.class); + String currentFieldName = skipBlockID(parser); + XContentParser.Token token; + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + currentFieldName = parser.currentName(); + } else if (token.isValue()) { + switch (Objects.requireNonNull(currentFieldName)) { + case KEY_UUID: + uuid = parser.text(); + break; + case KEY_DESCRIPTION: + description = parser.text(); + break; + case KEY_RETRYABLE: + retryable = parser.booleanValue(); + break; + case KEY_DISABLE_STATE_PERSISTENCE: + disableStatePersistence = parser.booleanValue(); + break; + case KEY_STATUS: + status = RestStatus.valueOf(parser.text()); + break; + case KEY_ALLOW_RELEASE_RESOURCES: + allowReleaseResources = parser.booleanValue(); + break; + default: + throw new IllegalArgumentException("unknown field [" + currentFieldName + "]"); + } + } else if (token == XContentParser.Token.START_ARRAY) { + if (currentFieldName.equals(KEY_LEVELS)) { + while ((token = parser.nextToken()) != XContentParser.Token.END_ARRAY) { + levels.add(ClusterBlockLevel.fromString(parser.text(), Locale.ROOT)); + } + } else { + throw new IllegalArgumentException("unknown field [" + currentFieldName + "]"); + } + } else { + throw new IllegalArgumentException("unexpected token [" + token + "]"); + } + } + return new ClusterBlock(id, uuid, description, retryable, disableStatePersistence, allowReleaseResources, status, levels); + } + + private static String skipBlockID(XContentParser parser) throws IOException { + if (parser.currentToken() == null) { + parser.nextToken(); + } + if (parser.currentToken() == XContentParser.Token.START_OBJECT) { + parser.nextToken(); + if (parser.currentToken() == XContentParser.Token.FIELD_NAME) { + String currentFieldName = parser.currentName(); + if (VALID_FIELDS.contains(currentFieldName)) { + return currentFieldName; + } else { + // we have hit block id, just move on + parser.nextToken(); + } + } + } + return null; + } + @Override public void writeTo(StreamOutput out) throws IOException { out.writeVInt(id); diff --git a/server/src/main/java/org/opensearch/cluster/block/ClusterBlockLevel.java b/server/src/main/java/org/opensearch/cluster/block/ClusterBlockLevel.java index 5d3bf94aedb19..4940234c21ba6 100644 --- a/server/src/main/java/org/opensearch/cluster/block/ClusterBlockLevel.java +++ b/server/src/main/java/org/opensearch/cluster/block/ClusterBlockLevel.java @@ -35,6 +35,7 @@ import org.opensearch.common.annotation.PublicApi; import java.util.EnumSet; +import java.util.Locale; /** * What level to block the cluster @@ -51,4 +52,11 @@ public enum ClusterBlockLevel { public static final EnumSet ALL = EnumSet.allOf(ClusterBlockLevel.class); public static final EnumSet READ_WRITE = EnumSet.of(READ, WRITE); + + /* + * This method is used to convert a string to a ClusterBlockLevel. + * */ + public static ClusterBlockLevel fromString(String level, Locale locale) { + return ClusterBlockLevel.valueOf(level.toUpperCase(locale)); + } } diff --git a/server/src/main/java/org/opensearch/cluster/block/ClusterBlocks.java b/server/src/main/java/org/opensearch/cluster/block/ClusterBlocks.java index 304136166d515..e188374251d0d 100644 --- a/server/src/main/java/org/opensearch/cluster/block/ClusterBlocks.java +++ b/server/src/main/java/org/opensearch/cluster/block/ClusterBlocks.java @@ -42,6 +42,11 @@ import org.opensearch.core.common.io.stream.StreamInput; import org.opensearch.core.common.io.stream.StreamOutput; import org.opensearch.core.rest.RestStatus; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.ToXContentFragment; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.core.xcontent.XContentParserUtils; import org.opensearch.index.IndexModule; import java.io.IOException; @@ -63,7 +68,7 @@ * @opensearch.api */ @PublicApi(since = "1.0.0") -public class ClusterBlocks extends AbstractDiffable { +public class ClusterBlocks extends AbstractDiffable implements ToXContentFragment { public static final ClusterBlocks EMPTY_CLUSTER_BLOCK = new ClusterBlocks(emptySet(), Map.of()); private final Set global; @@ -326,6 +331,16 @@ public static Diff readDiffFrom(StreamInput in) throws IOExceptio return AbstractDiffable.readDiffFrom(ClusterBlocks::readFrom, in); } + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + Builder.toXContext(this, builder, params); + return builder; + } + + public static ClusterBlocks fromXContent(XContentParser parser) throws IOException { + return Builder.fromXContent(parser); + } + /** * An immutable level holder. * @@ -427,10 +442,16 @@ public Builder removeGlobalBlock(int blockId) { } public Builder addIndexBlock(String index, ClusterBlock block) { + prepareIndexForBlocks(index); + indices.get(index).add(block); + return this; + } + + // initialize an index adding further blocks + private Builder prepareIndexForBlocks(String index) { if (!indices.containsKey(index)) { indices.put(index, new HashSet<>()); } - indices.get(index).add(block); return this; } @@ -480,5 +501,82 @@ public ClusterBlocks build() { } return new ClusterBlocks(unmodifiableSet(new HashSet<>(global)), indicesBuilder); } + + public static void toXContext(ClusterBlocks blocks, XContentBuilder builder, ToXContent.Params params) throws IOException { + builder.startObject("blocks"); + if (blocks.global().isEmpty() == false) { + builder.startObject("global"); + for (ClusterBlock block : blocks.global()) { + block.toXContent(builder, params); + } + builder.endObject(); + } + + if (blocks.indices().isEmpty() == false) { + builder.startObject("indices"); + for (Map.Entry> entry : blocks.indices().entrySet()) { + builder.startObject(entry.getKey()); + for (ClusterBlock block : entry.getValue()) { + block.toXContent(builder, params); + } + builder.endObject(); + } + builder.endObject(); + } + builder.endObject(); + } + + public static ClusterBlocks fromXContent(XContentParser parser) throws IOException { + Builder builder = new Builder(); + String currentFieldName = skipBlocksField(parser); + XContentParser.Token token; + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + XContentParserUtils.ensureExpectedToken(XContentParser.Token.FIELD_NAME, token, parser); + currentFieldName = parser.currentName(); + parser.nextToken(); + switch (currentFieldName) { + case "global": + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + currentFieldName = parser.currentName(); + parser.nextToken(); + builder.addGlobalBlock(ClusterBlock.fromXContent(parser, Integer.parseInt(currentFieldName))); + } + break; + case "indices": + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + String indexName = parser.currentName(); + parser.nextToken(); + // prepare for this index as we want to add this to ClusterBlocks even if there is no Block associated with it + builder.prepareIndexForBlocks(indexName); + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + currentFieldName = parser.currentName(); + parser.nextToken(); + builder.addIndexBlock(indexName, ClusterBlock.fromXContent(parser, Integer.parseInt(currentFieldName))); + } + } + break; + default: + throw new IllegalArgumentException("unknown field [" + currentFieldName + "]"); + } + } + return builder.build(); + } + + private static String skipBlocksField(XContentParser parser) throws IOException { + if (parser.currentToken() == null) { + parser.nextToken(); + } + if (parser.currentToken() == XContentParser.Token.START_OBJECT) { + parser.nextToken(); + if (parser.currentToken() == XContentParser.Token.FIELD_NAME) { + if ("blocks".equals(parser.currentName())) { + parser.nextToken(); + } else { + return parser.currentName(); + } + } + } + return null; + } } } diff --git a/server/src/test/java/org/opensearch/cluster/block/ClusterBlockTests.java b/server/src/test/java/org/opensearch/cluster/block/ClusterBlockTests.java index 04e04bd96a7d3..4ff20a20a9e40 100644 --- a/server/src/test/java/org/opensearch/cluster/block/ClusterBlockTests.java +++ b/server/src/test/java/org/opensearch/cluster/block/ClusterBlockTests.java @@ -33,18 +33,30 @@ package org.opensearch.cluster.block; import org.opensearch.Version; +import org.opensearch.cluster.metadata.Metadata; import org.opensearch.common.UUIDs; import org.opensearch.common.io.stream.BytesStreamOutput; +import org.opensearch.common.xcontent.json.JsonXContent; +import org.opensearch.core.common.bytes.BytesReference; import org.opensearch.core.common.io.stream.StreamInput; import org.opensearch.core.rest.RestStatus; +import org.opensearch.core.xcontent.MediaType; +import org.opensearch.core.xcontent.MediaTypeRegistry; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; import org.opensearch.test.OpenSearchTestCase; +import org.opensearch.test.XContentTestUtils; +import java.io.IOException; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Locale; import java.util.Map; +import static java.util.Collections.singletonMap; import static java.util.EnumSet.copyOf; +import static org.opensearch.cluster.metadata.Metadata.CONTEXT_MODE_GATEWAY; import static org.opensearch.test.VersionUtils.randomVersion; import static org.hamcrest.CoreMatchers.endsWith; import static org.hamcrest.CoreMatchers.equalTo; @@ -136,7 +148,119 @@ public void testGetIndexBlockWithId() { assertThat(builder.build().getIndexBlockWithId("index", randomValueOtherThan(blockId, OpenSearchTestCase::randomInt)), nullValue()); } - private ClusterBlock randomClusterBlock() { + public void testToXContent_APIMode() throws IOException { + ClusterBlock clusterBlock = randomClusterBlock(); + XContentBuilder builder = JsonXContent.contentBuilder().prettyPrint(); + builder.startObject(); + clusterBlock.toXContent(builder, ToXContent.EMPTY_PARAMS); + builder.endObject(); + + String expectedString = "{\n" + getExpectedXContentFragment(clusterBlock, " ", false) + "\n}"; + + assertEquals(expectedString, builder.toString()); + } + + public void testToXContent_GatewayMode() throws IOException { + ClusterBlock clusterBlock = randomClusterBlock(); + XContentBuilder builder = JsonXContent.contentBuilder().prettyPrint(); + builder.startObject(); + clusterBlock.toXContent(builder, new ToXContent.MapParams(singletonMap(Metadata.CONTEXT_MODE_PARAM, CONTEXT_MODE_GATEWAY))); + builder.endObject(); + + String expectedString = "{\n" + getExpectedXContentFragment(clusterBlock, " ", true) + "\n}"; + + assertEquals(expectedString, builder.toString()); + } + + public void testFromXContent() throws IOException { + doFromXContentTestWithRandomFields(false); + } + + public void testFromXContentWithRandomFields() throws IOException { + doFromXContentTestWithRandomFields(true); + } + + private void doFromXContentTestWithRandomFields(boolean addRandomFields) throws IOException { + ClusterBlock clusterBlock = randomClusterBlock(); + boolean humanReadable = randomBoolean(); + final MediaType mediaType = MediaTypeRegistry.JSON; + BytesReference originalBytes = toShuffledXContent(clusterBlock, mediaType, ToXContent.EMPTY_PARAMS, humanReadable); + + if (addRandomFields) { + String unsupportedField = "unsupported_field"; + BytesReference mutated = BytesReference.bytes( + XContentTestUtils.insertIntoXContent( + mediaType.xContent(), + originalBytes, + Collections.singletonList(Integer.toString(clusterBlock.id())), + () -> unsupportedField, + () -> randomAlphaOfLengthBetween(3, 10) + ) + ); + IllegalArgumentException e = expectThrows( + IllegalArgumentException.class, + () -> ClusterBlock.fromXContent(createParser(mediaType.xContent(), mutated), clusterBlock.id()) + ); + assertEquals(e.getMessage(), "unknown field [" + unsupportedField + "]"); + } else { + ClusterBlock parsedBlock = ClusterBlock.fromXContent(createParser(mediaType.xContent(), originalBytes), clusterBlock.id()); + assertEquals(clusterBlock, parsedBlock); + assertEquals(clusterBlock.description(), parsedBlock.description()); + assertEquals(clusterBlock.retryable(), parsedBlock.retryable()); + assertEquals(clusterBlock.disableStatePersistence(), parsedBlock.disableStatePersistence()); + assertArrayEquals(clusterBlock.levels().toArray(), parsedBlock.levels().toArray()); + } + } + + static String getExpectedXContentFragment(ClusterBlock clusterBlock, String indent, boolean gatewayMode) { + return indent + + "\"" + + clusterBlock.id() + + "\" : {\n" + + (clusterBlock.uuid() != null ? indent + " \"uuid\" : \"" + clusterBlock.uuid() + "\",\n" : "") + + indent + + " \"description\" : \"" + + clusterBlock.description() + + "\",\n" + + indent + + " \"retryable\" : " + + clusterBlock.retryable() + + ",\n" + + (clusterBlock.disableStatePersistence() + ? indent + " \"disable_state_persistence\" : " + clusterBlock.disableStatePersistence() + ",\n" + : "") + + String.format( + Locale.ROOT, + indent + " \"levels\" : [%s]", + clusterBlock.levels().isEmpty() + ? " " + : "\n" + + String.join( + ",\n", + clusterBlock.levels() + .stream() + .map(level -> indent + " \"" + level.name().toLowerCase(Locale.ROOT) + "\"") + .toArray(String[]::new) + ) + + "\n " + + indent + ) + + (gatewayMode + ? ",\n" + + indent + + " \"status\" : \"" + + clusterBlock.status() + + "\",\n" + + indent + + " \"allow_release_resources\" : " + + clusterBlock.isAllowReleaseResources() + + "\n" + : "\n") + + indent + + "}"; + } + + static ClusterBlock randomClusterBlock() { final String uuid = randomBoolean() ? UUIDs.randomBase64UUID() : null; final List levels = Arrays.asList(ClusterBlockLevel.values()); return new ClusterBlock( diff --git a/server/src/test/java/org/opensearch/cluster/block/ClusterBlocksTests.java b/server/src/test/java/org/opensearch/cluster/block/ClusterBlocksTests.java new file mode 100644 index 0000000000000..c1d4401760be3 --- /dev/null +++ b/server/src/test/java/org/opensearch/cluster/block/ClusterBlocksTests.java @@ -0,0 +1,151 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.cluster.block; + +import org.opensearch.common.xcontent.json.JsonXContent; +import org.opensearch.core.common.bytes.BytesReference; +import org.opensearch.core.xcontent.MediaType; +import org.opensearch.core.xcontent.MediaTypeRegistry; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.test.OpenSearchTestCase; +import org.opensearch.test.XContentTestUtils; + +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.opensearch.cluster.block.ClusterBlockTests.getExpectedXContentFragment; +import static org.opensearch.cluster.block.ClusterBlockTests.randomClusterBlock; + +public class ClusterBlocksTests extends OpenSearchTestCase { + public void testToXContent() throws IOException { + ClusterBlocks clusterBlocks = randomClusterBlocks(); + XContentBuilder builder = JsonXContent.contentBuilder().prettyPrint(); + builder.startObject(); + clusterBlocks.toXContent(builder, ToXContent.EMPTY_PARAMS); + builder.endObject(); + + String expectedXContent = "{\n" + + " \"blocks\" : {\n" + + String.format( + Locale.ROOT, + "%s", + clusterBlocks.global().isEmpty() + ? "" + : " \"global\" : {\n" + + clusterBlocks.global() + .stream() + .map(clusterBlock -> getExpectedXContentFragment(clusterBlock, " ", false)) + .collect(Collectors.joining(",\n")) + + "\n }" + + (!clusterBlocks.indices().isEmpty() ? "," : "") + + "\n" + ) + + String.format( + Locale.ROOT, + "%s", + clusterBlocks.indices().isEmpty() + ? "" + : " \"indices\" : {\n" + + clusterBlocks.indices() + .entrySet() + .stream() + .map( + entry -> " \"" + + entry.getKey() + + "\" : {" + + (entry.getValue().isEmpty() + ? " }" + : "\n" + + entry.getValue() + .stream() + .map(clusterBlock -> getExpectedXContentFragment(clusterBlock, " ", false)) + .collect(Collectors.joining(",\n")) + + "\n }") + ) + .collect(Collectors.joining(",\n")) + + "\n }\n" + ) + + " }\n" + + "}"; + + assertEquals(expectedXContent, builder.toString()); + } + + public void testFromXContent() throws IOException { + doFromXContentTestWithRandomFields(false); + } + + public void testFromXContentWithRandomFields() throws IOException { + doFromXContentTestWithRandomFields(true); + } + + private void doFromXContentTestWithRandomFields(boolean addRandomFields) throws IOException { + ClusterBlocks clusterBlocks = randomClusterBlocks(); + boolean humanReadable = randomBoolean(); + final MediaType mediaType = MediaTypeRegistry.JSON; + BytesReference originalBytes = toShuffledXContent(clusterBlocks, mediaType, ToXContent.EMPTY_PARAMS, humanReadable); + + if (addRandomFields) { + String unsupportedField = "unsupported_field"; + BytesReference mutated = BytesReference.bytes( + XContentTestUtils.insertIntoXContent( + mediaType.xContent(), + originalBytes, + Collections.singletonList("blocks"), + () -> unsupportedField, + () -> randomAlphaOfLengthBetween(3, 10) + ) + ); + IllegalArgumentException exception = expectThrows( + IllegalArgumentException.class, + () -> ClusterBlocks.fromXContent(createParser(mediaType.xContent(), mutated)) + ); + assertEquals("unknown field [" + unsupportedField + "]", exception.getMessage()); + } else { + try (XContentParser parser = createParser(JsonXContent.jsonXContent, originalBytes)) { + ClusterBlocks parsedClusterBlocks = ClusterBlocks.fromXContent(parser); + assertEquals(clusterBlocks.global().size(), parsedClusterBlocks.global().size()); + assertEquals(clusterBlocks.indices().size(), parsedClusterBlocks.indices().size()); + clusterBlocks.global().forEach(clusterBlock -> assertTrue(parsedClusterBlocks.global().contains(clusterBlock))); + clusterBlocks.indices().forEach((key, value) -> { + assertTrue(parsedClusterBlocks.indices().containsKey(key)); + value.forEach(clusterBlock -> assertTrue(parsedClusterBlocks.indices().get(key).contains(clusterBlock))); + }); + } + } + } + + private ClusterBlocks randomClusterBlocks() { + int randomGlobalBlocks = randomIntBetween(0, 10); + Set globalBlocks = new HashSet<>(); + for (int i = 0; i < randomGlobalBlocks; i++) { + globalBlocks.add(randomClusterBlock()); + } + + int randomIndices = randomIntBetween(0, 10); + Map> indexBlocks = new HashMap<>(); + for (int i = 0; i < randomIndices; i++) { + int randomIndexBlocks = randomIntBetween(0, 10); + Set blocks = new HashSet<>(); + for (int j = 0; j < randomIndexBlocks; j++) { + blocks.add(randomClusterBlock()); + } + indexBlocks.put("index-" + i, blocks); + } + return new ClusterBlocks(globalBlocks, indexBlocks); + } +}