Add QA test module for Lucene N-2 version (#118363)

This change introduces a new QA project to test Lucene 
support for reading indices created in version N-2.

The test suite is inspired from the various full-cluster 
restart suites we already have. It creates a cluster in 
version N-2 (today 7.17.25), then upgrades the cluster 
to N-1 (today 8.18.0) and finally upgrades the cluster 
to the current version (today 9.0), allowing to execute 
test methods after every upgrade.

The test suite has two variants: one for searchable 
snapshots and one for snapshot restore. The suites 
demonstrates that Elasticsearch does not allow 
reading indices written in version N-2 but we hope 
to make this feasible. Also, the tests can be used for 
investigation and debug with the command 
`./gradlew ":qa:lucene-index-compatibility:check" --debug-jvm-server`

Relates ES-10274
This commit is contained in:
Tanguy Leroux 2024-12-11 17:03:52 +01:00 committed by GitHub
parent 912d37abef
commit e0ad97e8d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 411 additions and 0 deletions

View File

@ -252,6 +252,20 @@ public class BwcVersions implements Serializable {
.toList();
}
public List<Version> getReadOnlyIndexCompatible() {
// Lucene can read indices in version N-2
int compatibleMajor = currentVersion.getMajor() - 2;
return versions.stream().filter(v -> v.getMajor() == compatibleMajor).sorted(Comparator.naturalOrder()).toList();
}
public void withLatestReadOnlyIndexCompatible(Consumer<Version> versionAction) {
var compatibleVersions = getReadOnlyIndexCompatible();
if (compatibleVersions == null || compatibleVersions.isEmpty()) {
throw new IllegalStateException("No read-only compatible version found.");
}
versionAction.accept(compatibleVersions.getLast());
}
/**
* Return versions of Elasticsearch which are index compatible with the current version.
*/

View File

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
apply plugin: 'elasticsearch.internal-java-rest-test'
apply plugin: 'elasticsearch.internal-test-artifact'
apply plugin: 'elasticsearch.bwc-test'
buildParams.bwcVersions.withLatestReadOnlyIndexCompatible { bwcVersion ->
tasks.named("javaRestTest").configure {
systemProperty("tests.minimum.index.compatible", bwcVersion)
usesBwcDistribution(bwcVersion)
enabled = true
}
}
tasks.withType(Test).configureEach {
// CI doesn't like it when there's multiple clusters running at once
maxParallelForks = 1
}

View File

@ -0,0 +1,141 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
package org.elasticsearch.lucene;
import com.carrotsearch.randomizedtesting.TestMethodAndParams;
import com.carrotsearch.randomizedtesting.annotations.Name;
import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
import com.carrotsearch.randomizedtesting.annotations.TestCaseOrdering;
import org.elasticsearch.client.Request;
import org.elasticsearch.test.cluster.ElasticsearchCluster;
import org.elasticsearch.test.cluster.local.LocalClusterConfigProvider;
import org.elasticsearch.test.cluster.local.distribution.DistributionType;
import org.elasticsearch.test.cluster.util.Version;
import org.elasticsearch.test.rest.ESRestTestCase;
import org.junit.Before;
import org.junit.ClassRule;
import org.junit.rules.RuleChain;
import org.junit.rules.TemporaryFolder;
import org.junit.rules.TestRule;
import java.util.Comparator;
import java.util.Locale;
import java.util.stream.Stream;
import static org.elasticsearch.test.cluster.util.Version.CURRENT;
import static org.elasticsearch.test.cluster.util.Version.fromString;
import static org.elasticsearch.test.rest.ObjectPath.createFromResponse;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.notNullValue;
/**
* Test suite for Lucene indices backward compatibility with N-2 versions. The test suite creates a cluster in N-2 version, then upgrades it
* to N-1 version and finally upgrades it to the current version. Test methods are executed after each upgrade.
*/
@TestCaseOrdering(AbstractLuceneIndexCompatibilityTestCase.TestCaseOrdering.class)
public abstract class AbstractLuceneIndexCompatibilityTestCase extends ESRestTestCase {
protected static final Version VERSION_MINUS_2 = fromString(System.getProperty("tests.minimum.index.compatible"));
protected static final Version VERSION_MINUS_1 = fromString(System.getProperty("tests.minimum.wire.compatible"));
protected static final Version VERSION_CURRENT = CURRENT;
protected static TemporaryFolder REPOSITORY_PATH = new TemporaryFolder();
protected static LocalClusterConfigProvider clusterConfig = c -> {};
private static ElasticsearchCluster cluster = ElasticsearchCluster.local()
.distribution(DistributionType.DEFAULT)
.version(VERSION_MINUS_2)
.nodes(2)
.setting("path.repo", () -> REPOSITORY_PATH.getRoot().getPath())
.setting("xpack.security.enabled", "false")
.setting("xpack.ml.enabled", "false")
.setting("path.repo", () -> REPOSITORY_PATH.getRoot().getPath())
.apply(() -> clusterConfig)
.build();
@ClassRule
public static TestRule ruleChain = RuleChain.outerRule(REPOSITORY_PATH).around(cluster);
private static boolean upgradeFailed = false;
private final Version clusterVersion;
public AbstractLuceneIndexCompatibilityTestCase(@Name("cluster") Version clusterVersion) {
this.clusterVersion = clusterVersion;
}
@ParametersFactory
public static Iterable<Object[]> parameters() {
return Stream.of(VERSION_MINUS_2, VERSION_MINUS_1, CURRENT).map(v -> new Object[] { v }).toList();
}
@Override
protected String getTestRestCluster() {
return cluster.getHttpAddresses();
}
@Override
protected boolean preserveClusterUponCompletion() {
return true;
}
@Before
public void maybeUpgrade() throws Exception {
// We want to use this test suite for the V9 upgrade, but we are not fully committed to necessarily having N-2 support
// in V10, so we add a check here to ensure we'll revisit this decision once V10 exists.
assertThat("Explicit check that N-2 version is Elasticsearch 7", VERSION_MINUS_2.getMajor(), equalTo(7));
var currentVersion = clusterVersion();
if (currentVersion.before(clusterVersion)) {
try {
cluster.upgradeToVersion(clusterVersion);
closeClients();
initClient();
} catch (Exception e) {
upgradeFailed = true;
throw e;
}
}
// Skip remaining tests if upgrade failed
assumeFalse("Cluster upgrade failed", upgradeFailed);
}
protected String suffix(String name) {
return name + '-' + getTestName().split(" ")[0].toLowerCase(Locale.ROOT);
}
protected static Version clusterVersion() throws Exception {
var response = assertOK(client().performRequest(new Request("GET", "/")));
var responseBody = createFromResponse(response);
var version = Version.fromString(responseBody.evaluate("version.number").toString());
assertThat("Failed to retrieve cluster version", version, notNullValue());
return version;
}
protected static Version indexLuceneVersion(String indexName) throws Exception {
var response = assertOK(client().performRequest(new Request("GET", "/" + indexName + "/_settings")));
int id = Integer.parseInt(createFromResponse(response).evaluate(indexName + ".settings.index.version.created"));
return new Version((byte) ((id / 1000000) % 100), (byte) ((id / 10000) % 100), (byte) ((id / 100) % 100));
}
/**
* Execute the test suite with the parameters provided by the {@link #parameters()} in version order.
*/
public static class TestCaseOrdering implements Comparator<TestMethodAndParams> {
@Override
public int compare(TestMethodAndParams o1, TestMethodAndParams o2) {
var version1 = (Version) o1.getInstanceArguments().get(0);
var version2 = (Version) o2.getInstanceArguments().get(0);
return version1.compareTo(version2);
}
}
}

View File

@ -0,0 +1,114 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
package org.elasticsearch.lucene;
import org.elasticsearch.client.Request;
import org.elasticsearch.cluster.metadata.IndexMetadata;
import org.elasticsearch.common.Strings;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.index.IndexSettings;
import org.elasticsearch.repositories.fs.FsRepository;
import org.elasticsearch.test.cluster.util.Version;
import java.util.stream.IntStream;
import static org.elasticsearch.test.rest.ObjectPath.createFromResponse;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;
public class LuceneCompatibilityIT extends AbstractLuceneIndexCompatibilityTestCase {
static {
clusterConfig = config -> config.setting("xpack.license.self_generated.type", "trial");
}
public LuceneCompatibilityIT(Version version) {
super(version);
}
public void testRestoreIndex() throws Exception {
final String repository = suffix("repository");
final String snapshot = suffix("snapshot");
final String index = suffix("index");
final int numDocs = 1234;
logger.debug("--> registering repository [{}]", repository);
registerRepository(
client(),
repository,
FsRepository.TYPE,
true,
Settings.builder().put("location", REPOSITORY_PATH.getRoot().getPath()).build()
);
if (VERSION_MINUS_2.equals(clusterVersion())) {
logger.debug("--> creating index [{}]", index);
createIndex(
client(),
index,
Settings.builder()
.put(IndexMetadata.SETTING_NUMBER_OF_SHARDS, 1)
.put(IndexMetadata.SETTING_NUMBER_OF_REPLICAS, 0)
.put(IndexSettings.INDEX_SOFT_DELETES_SETTING.getKey(), true)
.build()
);
logger.debug("--> indexing [{}] docs in [{}]", numDocs, index);
final var bulks = new StringBuilder();
IntStream.range(0, numDocs).forEach(n -> bulks.append(Strings.format("""
{"index":{"_id":"%s","_index":"%s"}}
{"test":"test"}
""", n, index)));
var bulkRequest = new Request("POST", "/_bulk");
bulkRequest.setJsonEntity(bulks.toString());
var bulkResponse = client().performRequest(bulkRequest);
assertOK(bulkResponse);
assertThat(entityAsMap(bulkResponse).get("errors"), allOf(notNullValue(), is(false)));
logger.debug("--> creating snapshot [{}]", snapshot);
createSnapshot(client(), repository, snapshot, true);
return;
}
if (VERSION_MINUS_1.equals(clusterVersion())) {
ensureGreen(index);
assertThat(indexLuceneVersion(index), equalTo(VERSION_MINUS_2));
assertDocCount(client(), index, numDocs);
logger.debug("--> deleting index [{}]", index);
deleteIndex(index);
return;
}
if (VERSION_CURRENT.equals(clusterVersion())) {
var restoredIndex = suffix("index-restored");
logger.debug("--> restoring index [{}] as archive [{}]", index, restoredIndex);
// Restoring the archive will fail as Elasticsearch does not support reading N-2 yet
var request = new Request("POST", "/_snapshot/" + repository + "/" + snapshot + "/_restore");
request.addParameter("wait_for_completion", "true");
request.setJsonEntity(Strings.format("""
{
"indices": "%s",
"include_global_state": false,
"rename_pattern": "(.+)",
"rename_replacement": "%s",
"include_aliases": false
}""", index, restoredIndex));
var responseBody = createFromResponse(client().performRequest(request));
assertThat(responseBody.evaluate("snapshot.shards.total"), equalTo((int) responseBody.evaluate("snapshot.shards.failed")));
assertThat(responseBody.evaluate("snapshot.shards.successful"), equalTo(0));
}
}
}

View File

@ -0,0 +1,117 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
package org.elasticsearch.lucene;
import org.elasticsearch.client.Request;
import org.elasticsearch.cluster.metadata.IndexMetadata;
import org.elasticsearch.common.Strings;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.index.IndexSettings;
import org.elasticsearch.repositories.fs.FsRepository;
import org.elasticsearch.test.cluster.util.Version;
import java.util.stream.IntStream;
import static org.elasticsearch.test.rest.ObjectPath.createFromResponse;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;
public class SearchableSnapshotCompatibilityIT extends AbstractLuceneIndexCompatibilityTestCase {
static {
clusterConfig = config -> config.setting("xpack.license.self_generated.type", "trial")
.setting("xpack.searchable.snapshot.shared_cache.size", "16MB")
.setting("xpack.searchable.snapshot.shared_cache.region_size", "256KB");
}
public SearchableSnapshotCompatibilityIT(Version version) {
super(version);
}
// TODO Add a test to mount the N-2 index on N-1 and then search it on N
public void testSearchableSnapshot() throws Exception {
final String repository = suffix("repository");
final String snapshot = suffix("snapshot");
final String index = suffix("index");
final int numDocs = 1234;
logger.debug("--> registering repository [{}]", repository);
registerRepository(
client(),
repository,
FsRepository.TYPE,
true,
Settings.builder().put("location", REPOSITORY_PATH.getRoot().getPath()).build()
);
if (VERSION_MINUS_2.equals(clusterVersion())) {
logger.debug("--> creating index [{}]", index);
createIndex(
client(),
index,
Settings.builder()
.put(IndexMetadata.SETTING_NUMBER_OF_SHARDS, 1)
.put(IndexMetadata.SETTING_NUMBER_OF_REPLICAS, 0)
.put(IndexSettings.INDEX_SOFT_DELETES_SETTING.getKey(), true)
.build()
);
logger.debug("--> indexing [{}] docs in [{}]", numDocs, index);
final var bulks = new StringBuilder();
IntStream.range(0, numDocs).forEach(n -> bulks.append(Strings.format("""
{"index":{"_id":"%s","_index":"%s"}}
{"test":"test"}
""", n, index)));
var bulkRequest = new Request("POST", "/_bulk");
bulkRequest.setJsonEntity(bulks.toString());
var bulkResponse = client().performRequest(bulkRequest);
assertOK(bulkResponse);
assertThat(entityAsMap(bulkResponse).get("errors"), allOf(notNullValue(), is(false)));
logger.debug("--> creating snapshot [{}]", snapshot);
createSnapshot(client(), repository, snapshot, true);
return;
}
if (VERSION_MINUS_1.equals(clusterVersion())) {
ensureGreen(index);
assertThat(indexLuceneVersion(index), equalTo(VERSION_MINUS_2));
assertDocCount(client(), index, numDocs);
logger.debug("--> deleting index [{}]", index);
deleteIndex(index);
return;
}
if (VERSION_CURRENT.equals(clusterVersion())) {
var mountedIndex = suffix("index-mounted");
logger.debug("--> mounting index [{}] as [{}]", index, mountedIndex);
// Mounting the index will fail as Elasticsearch does not support reading N-2 yet
var request = new Request("POST", "/_snapshot/" + repository + "/" + snapshot + "/_mount");
request.addParameter("wait_for_completion", "true");
var storage = randomBoolean() ? "shared_cache" : "full_copy";
request.addParameter("storage", storage);
request.setJsonEntity(Strings.format("""
{
"index": "%s",
"renamed_index": "%s"
}""", index, mountedIndex));
var responseBody = createFromResponse(client().performRequest(request));
assertThat(responseBody.evaluate("snapshot.shards.total"), equalTo((int) responseBody.evaluate("snapshot.shards.failed")));
assertThat(responseBody.evaluate("snapshot.shards.successful"), equalTo(0));
}
}
}