diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareRemoteStoreRecoveryIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareRemoteStoreRecoveryIT.java
index 328469c6f2bdb..bd7e43a159072 100644
--- a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareRemoteStoreRecoveryIT.java
+++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareRemoteStoreRecoveryIT.java
@@ -396,6 +396,70 @@ public void testRemoteStoreRecoveryFromTranslogOnly() throws Exception {
}, 60, TimeUnit.SECONDS);
}
+ /**
+ * DFA correctness regression guard: recovery from remote store must replay
+ * translog without re-applying operations already committed to durable Parquet/Lucene files.
+ *
+ *
Scenario:
+ *
+ * Index batch 1 → refresh + flush (writes durable Parquet/Lucene files; uploads to
+ * remote segment store).
+ * Index batch 2 → refresh only (resides in translog + remote translog only, NOT yet in
+ * any committed format file).
+ * Full cluster restart — the data node downloads durable files from the remote segment
+ * store and replays the remote translog.
+ *
+ *
+ * If the translog-replay path fails to skip operations whose seq_no is already covered by
+ * the recovered commit's local checkpoint, batch 1 ops would be re-applied on top of the
+ * already-restored Parquet files → duplicate rows. The standard Lucene engine handles this via
+ * {@code max_seq_no} tracking in commit user-data; for DFA the {@link
+ * org.opensearch.index.engine.exec.coord.CatalogSnapshot} must carry the equivalent so the
+ * recovery path can correctly bound the replay range.
+ *
+ *
Asserts that the post-restart doc count equals exactly {@code firstBatch + secondBatch}.
+ * Any value above that indicates a duplication regression.
+ */
+ public void testTranslogReplayDoesNotDuplicateRowsWithRemoteStore() throws Exception {
+ internalCluster().startClusterManagerOnlyNode();
+ internalCluster().startDataOnlyNode();
+
+ client().admin().indices().prepareCreate(INDEX_NAME).setSettings(dfaIndexSettings(0)).get();
+ ensureGreen(INDEX_NAME);
+
+ // Phase 1: indexed AND committed (durable in Parquet/Lucene; uploaded to remote segment store)
+ int firstBatch = randomIntBetween(10, 30);
+ indexDocs(firstBatch);
+ client().admin().indices().prepareRefresh(INDEX_NAME).get();
+ client().admin().indices().prepareFlush(INDEX_NAME).setForce(true).setWaitIfOngoing(true).get();
+
+ // Phase 2: indexed but NOT committed — lives only in translog + remote translog
+ int secondBatch = randomIntBetween(5, 15);
+ indexDocsWithOffset(firstBatch, secondBatch);
+ client().admin().indices().prepareRefresh(INDEX_NAME).get();
+ // Intentionally NO flush — second batch must remain in translog only
+
+ long expectedTotal = firstBatch + secondBatch;
+
+ // Full cluster restart — exercises the realistic disaster-recovery path: data node restarts,
+ // downloads committed files from remote segment store, then replays the remote translog.
+ internalCluster().fullRestart();
+ ensureGreen(INDEX_NAME);
+
+ long actualRows = client().admin().indices().prepareStats(INDEX_NAME).clear().setDocs(true).get().getTotal().getDocs().getCount();
+
+ assertEquals(
+ "Translog replay must NOT duplicate rows already committed to Parquet. "
+ + "Expected "
+ + expectedTotal
+ + " but got "
+ + actualRows
+ + ". If actual > expected, translog replayed ops that were already in committed Parquet files.",
+ expectedTotal,
+ actualRows
+ );
+ }
+
/**
* Restart primary node (not full restore), verify it recovers with all format files
* intact on disk and catalog matching.
diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareRestoreShallowSnapshotV2IT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareRestoreShallowSnapshotV2IT.java
new file mode 100644
index 0000000000000..65eabf825527d
--- /dev/null
+++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareRestoreShallowSnapshotV2IT.java
@@ -0,0 +1,1938 @@
+/*
+ * 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.composite;
+
+import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
+
+import org.opensearch.action.admin.cluster.repositories.get.GetRepositoriesRequest;
+import org.opensearch.action.admin.cluster.repositories.get.GetRepositoriesResponse;
+import org.opensearch.action.admin.cluster.snapshots.restore.RestoreSnapshotResponse;
+import org.opensearch.action.admin.indices.delete.DeleteIndexRequest;
+import org.opensearch.action.support.clustermanager.AcknowledgedResponse;
+import org.opensearch.arrow.allocator.ArrowBasePlugin;
+import org.opensearch.be.datafusion.DataFusionPlugin;
+import org.opensearch.be.lucene.LucenePlugin;
+import org.opensearch.cluster.ClusterState;
+import org.opensearch.cluster.metadata.IndexMetadata;
+import org.opensearch.cluster.metadata.RepositoryMetadata;
+import org.opensearch.common.settings.Settings;
+import org.opensearch.common.unit.TimeValue;
+import org.opensearch.common.util.FeatureFlags;
+import org.opensearch.core.common.unit.ByteSizeUnit;
+import org.opensearch.core.common.unit.ByteSizeValue;
+import org.opensearch.core.index.shard.ShardId;
+import org.opensearch.core.rest.RestStatus;
+import org.opensearch.index.IndexModule;
+import org.opensearch.index.IndexSettings;
+import org.opensearch.index.remote.RemoteStoreEnums;
+import org.opensearch.index.shard.IndexShard;
+import org.opensearch.indices.RemoteStoreSettings;
+import org.opensearch.indices.replication.common.ReplicationType;
+import org.opensearch.node.Node;
+import org.opensearch.node.remotestore.RemoteStorePinnedTimestampService;
+import org.opensearch.parquet.ParquetDataFormatPlugin;
+import org.opensearch.plugins.Plugin;
+import org.opensearch.repositories.blobstore.BlobStoreRepository;
+import org.opensearch.snapshots.AbstractSnapshotIntegTestCase;
+import org.opensearch.snapshots.SnapshotInfo;
+import org.opensearch.snapshots.SnapshotState;
+import org.opensearch.test.OpenSearchIntegTestCase;
+import org.opensearch.transport.client.Client;
+import org.junit.After;
+import org.junit.Before;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.opensearch.cluster.metadata.IndexMetadata.SETTING_REMOTE_SEGMENT_STORE_REPOSITORY;
+import static org.opensearch.cluster.metadata.IndexMetadata.SETTING_REMOTE_STORE_ENABLED;
+import static org.opensearch.cluster.metadata.IndexMetadata.SETTING_REMOTE_TRANSLOG_STORE_REPOSITORY;
+import static org.opensearch.common.util.FeatureFlags.WRITABLE_WARM_INDEX_SETTING;
+import static org.opensearch.indices.RemoteStoreSettings.CLUSTER_REMOTE_STORE_PATH_TYPE_SETTING;
+import static org.opensearch.repositories.blobstore.BlobStoreRepository.SYSTEM_REPOSITORY_SETTING;
+import static org.opensearch.test.hamcrest.OpenSearchAssertions.assertAcked;
+import static org.hamcrest.Matchers.equalTo;
+
+@OpenSearchIntegTestCase.ClusterScope(scope = OpenSearchIntegTestCase.Scope.TEST, numDataNodes = 0)
+public class DataFormatAwareRestoreShallowSnapshotV2IT extends AbstractSnapshotIntegTestCase {
+
+ private static final String BASE_REMOTE_REPO = "test-rs-repo" + TEST_REMOTE_STORE_REPO_SUFFIX;
+ private Path remoteRepoPath;
+
+ public DataFormatAwareRestoreShallowSnapshotV2IT(Settings nodeSettings) {
+ super(nodeSettings);
+ }
+
+ @Before
+ public void setup() {
+ remoteRepoPath = randomRepoPath().toAbsolutePath();
+ }
+
+ @After
+ public void teardown() {
+ clusterAdmin().prepareCleanupRepository(BASE_REMOTE_REPO).get();
+ if (WRITABLE_WARM_INDEX_SETTING.get(settings)) {
+ assertAcked(client().admin().indices().prepareDelete("_all").get());
+ var nodes = internalCluster().getDataNodeInstances(Node.class);
+ for (var node : nodes) {
+ var fileCache = node.fileCache();
+ fileCache.clear();
+ }
+ }
+ }
+
+ @ParametersFactory
+ public static Collection parameters() {
+ // For DFA + V2 test, warm-index is orthogonal and slows test; use only non-warm variant.
+ return Arrays.asList(new Object[] { Settings.builder().put(WRITABLE_WARM_INDEX_SETTING.getKey(), false).build() });
+ }
+
+ @Override
+ protected Settings nodeSettings(int nodeOrdinal) {
+ ByteSizeValue cacheSize = new ByteSizeValue(16, ByteSizeUnit.GB);
+ return Settings.builder()
+ .put(super.nodeSettings(nodeOrdinal))
+ .put(remoteStoreClusterSettings(BASE_REMOTE_REPO, remoteRepoPath))
+ .put(RemoteStoreSettings.CLUSTER_REMOTE_STORE_PINNED_TIMESTAMP_ENABLED.getKey(), true)
+ .put(Node.NODE_SEARCH_CACHE_SIZE_SETTING.getKey(), cacheSize.toString())
+ .put(FeatureFlags.PLUGGABLE_DATAFORMAT_EXPERIMENTAL_FLAG, true)
+ .build();
+ }
+
+ @Override
+ protected Collection> nodePlugins() {
+ return Stream.concat(
+ super.nodePlugins().stream(),
+ Stream.of(
+ ArrowBasePlugin.class,
+ ParquetDataFormatPlugin.class,
+ CompositeDataFormatPlugin.class,
+ LucenePlugin.class,
+ DataFusionPlugin.class
+ )
+ ).collect(Collectors.toList());
+ }
+
+ @Override
+ protected Settings.Builder getRepositorySettings(Path location, boolean shallowCopyEnabled) {
+ Settings.Builder settingsBuilder = randomRepositorySettings();
+ settingsBuilder.put("location", location);
+ if (shallowCopyEnabled) {
+ settingsBuilder.put(BlobStoreRepository.REMOTE_STORE_INDEX_SHALLOW_COPY.getKey(), true)
+ .put(BlobStoreRepository.SHALLOW_SNAPSHOT_V2.getKey(), true);
+ }
+ return settingsBuilder;
+ }
+
+ protected Settings.Builder getRepositorySettings(String sourceRepository, boolean readOnly) throws ExecutionException,
+ InterruptedException {
+ GetRepositoriesRequest gr = new GetRepositoriesRequest(new String[] { sourceRepository });
+ GetRepositoriesResponse res = client().admin().cluster().getRepositories(gr).get();
+ RepositoryMetadata rmd = res.repositories().get(0);
+ return Settings.builder()
+ .put(rmd.settings())
+ .put(BlobStoreRepository.READONLY_SETTING.getKey(), readOnly)
+ .put(BlobStoreRepository.SHALLOW_SNAPSHOT_V2.getKey(), false)
+ .put(SYSTEM_REPOSITORY_SETTING.getKey(), false);
+ }
+
+ /**
+ * Secondary data formats for the DFA index. By default this is empty (parquet-only).
+ * Subclasses (e.g. {@code DataFormatAwareRestoreShallowSnapshotV2WithLuceneIT}) override
+ * this to add lucene as a secondary format.
+ */
+ protected List getSecondaryDataFormats() {
+ return List.of();
+ }
+
+ /**
+ * Whether lucene is configured as a secondary format. Drives format-aware on-disk assertions.
+ */
+ protected boolean hasLuceneSecondary() {
+ return false;
+ }
+
+ protected Settings.Builder getIndexSettings(int numOfShards, int numOfReplicas) {
+ Settings.Builder settingsBuilder = Settings.builder()
+ .put(super.indexSettings())
+ .put(IndexMetadata.SETTING_NUMBER_OF_SHARDS, numOfShards)
+ .put(IndexMetadata.SETTING_NUMBER_OF_REPLICAS, numOfReplicas)
+ .put(IndexSettings.INDEX_REFRESH_INTERVAL_SETTING.getKey(), "300s")
+ .put(IndexMetadata.SETTING_REPLICATION_TYPE, ReplicationType.SEGMENT)
+ .put("index.pluggable.dataformat.enabled", true)
+ .put("index.pluggable.dataformat", "composite")
+ .put("index.composite.primary_data_format", "parquet")
+ .putList("index.composite.secondary_data_formats", getSecondaryDataFormats());
+ if (WRITABLE_WARM_INDEX_SETTING.get(settings)) {
+ settingsBuilder.put(IndexModule.IS_WARM_INDEX_SETTING.getKey(), true);
+ }
+ return settingsBuilder;
+ }
+
+ private void indexDocuments(Client client, String indexName, int numOfDocs) {
+ indexDocuments(client, indexName, 0, numOfDocs);
+ }
+
+ protected void indexDocuments(Client client, String indexName, int fromId, int toId) {
+ for (int i = fromId; i < toId; i++) {
+ String id = Integer.toString(i);
+ client.prepareIndex(indexName).setId(id).setSource("text", "sometext").get();
+ }
+ }
+
+ /**
+ * Validate doc count via _stats API (uses engine's docStats(), not search).
+ * Search isn't supported on DFA indices yet, so we avoid prepareSearch / prepareGet.
+ */
+ private void assertDocCountInIndex(Client client, String indexName, long expectedCount) {
+ long actualCount = client.admin()
+ .indices()
+ .prepareStats(indexName)
+ .clear()
+ .setDocs(true)
+ .get()
+ .getIndex(indexName)
+ .getPrimaries().docs.getCount();
+ assertEquals(
+ "doc count mismatch for index " + indexName + " (expected " + expectedCount + ", got " + actualCount + ")",
+ expectedCount,
+ actualCount
+ );
+ }
+
+ /**
+ * Set of formats this test's index uses. Matches the index settings:
+ * primary=parquet, secondary=[lucene]. Lucene is also always present implicitly.
+ */
+ private Set expectedFormats() {
+ return Set.of("parquet", "lucene");
+ }
+
+ /**
+ * Assert each non-Lucene format directory exists under the shard data path and contains files.
+ * Mirrors {@code DataFormatAwareReplicationBaseIT.assertAllFormatDirsHaveFiles}.
+ */
+ private void assertAllFormatDirsHaveFiles(IndexShard shard) throws IOException {
+ for (String format : expectedFormats()) {
+ if ("lucene".equals(format)) continue; // Lucene files live under shard/index/, not a separate dir
+ Path dir = shard.shardPath().getDataPath().resolve(format);
+ assertTrue("format directory must exist: " + format + " (" + dir + ")", Files.exists(dir));
+ try (var stream = Files.list(dir)) {
+ assertTrue("format directory must have files: " + format + " (" + dir + ")", stream.findAny().isPresent());
+ }
+ }
+ }
+
+ /**
+ * Assert the Lucene index/ directory contents match the configured layout:
+ *
+ * parquet+lucene-secondary: index/ has segments_N + at least one segment data file
+ * parquet-only: index/ has only segments_N (and possibly write.lock); no segment data
+ *
+ * Mirrors {@code DataFormatAwareReplicationBaseIT.assertLuceneIndexDirContents}.
+ */
+ private void assertLuceneIndexDirContents(IndexShard shard) throws IOException {
+ Path indexDir = shard.shardPath().resolveIndex();
+ assertTrue("index/ directory must exist: " + indexDir, Files.exists(indexDir));
+ Set files;
+ try (var stream = Files.list(indexDir)) {
+ files = stream.map(p -> p.getFileName().toString()).collect(java.util.stream.Collectors.toSet());
+ }
+ assertTrue("index/ must contain segments_N, got " + files, files.stream().anyMatch(f -> f.startsWith("segments_")));
+ Set nonSegmentsFiles = files.stream()
+ .filter(f -> f.startsWith("segments_") == false && f.equals("write.lock") == false)
+ .collect(java.util.stream.Collectors.toSet());
+ if (hasLuceneSecondary()) {
+ assertFalse(
+ "parquet+lucene-secondary: index/ must have segment data files beyond segments_N, got only: " + files,
+ nonSegmentsFiles.isEmpty()
+ );
+ } else {
+ assertTrue(
+ "parquet-only: index/ must have only segments_N (and write.lock), got extra: " + nonSegmentsFiles,
+ nonSegmentsFiles.isEmpty()
+ );
+ }
+ }
+
+ /**
+ * Get the IndexShard for shard 0 of the index on the first data node in the cluster.
+ */
+ private IndexShard getShardZero(String indexName) {
+ String node = internalCluster().getDataNodeNames().iterator().next();
+ return getIndexShard(node, new ShardId(resolveIndex(indexName), 0), indexName);
+ }
+
+ /**
+ * End-to-end snapshot V2 + restore test for a DFA (parquet primary, lucene secondary) index.
+ *
+ * Pre-snapshot assertions:
+ *
+ * Doc count via _stats matches what was indexed
+ * All format directories (parquet, lucene index/) exist with expected files
+ * Catalog files match local store and remote store
+ *
+ *
+ * Snapshot V2 assertions:
+ *
+ * Snapshot state == SUCCESS, all shards successful (cluster-manager-only flow)
+ *
+ *
+ * Post-restore assertions:
+ *
+ * Restore status == OK
+ * Restored index reaches green health
+ * Restored doc count matches
+ * All format dirs (parquet, lucene index/) reconstructed on disk with files
+ * Catalog files match local store and remote store on the restored shard
+ *
+ *
+ * Note: does not use prepareSearch or prepareGet because search is not yet supported
+ * on DFA indices. Validation goes through admin APIs (_stats, cluster state) and direct
+ * shard inspection (catalog snapshot, on-disk format dirs, remote store listing).
+ *
+ *
V2 path is triggered by passing an empty indices list to createSnapshot:
+ * {@code request.indices().length == 0} is part of the V2 gate in
+ * {@link org.opensearch.snapshots.SnapshotsService} (~line 293).
+ */
+ public void testV2SnapshotCreateAndRestoreForDFAIndex() throws Exception {
+ internalCluster().startClusterManagerOnlyNode();
+ internalCluster().startDataAndWarmNodes(1);
+ String indexName = "dfa-testindex";
+ String snapshotRepoName = "test-snapshot-repo";
+ String snapshotName = "test-v2-snapshot";
+ Path absolutePath1 = randomRepoPath().toAbsolutePath();
+ logger.info("Snapshot Path [{}]", absolutePath1);
+
+ // Create snapshot repo with V2 enabled
+ createRepository(snapshotRepoName, "fs", getRepositorySettings(absolutePath1, true));
+
+ // Create DFA index (parquet primary + lucene secondary, see getIndexSettings)
+ Client client = client();
+ Settings indexSettings = getIndexSettings(1, 0).build();
+ createIndex(indexName, indexSettings);
+ ensureGreen(indexName);
+
+ // Index documents and refresh so segments are created in BOTH formats
+ final int numDocs = 10;
+ indexDocuments(client, indexName, 0, numDocs);
+ refresh(indexName);
+ flush(indexName); // Force a commit so catalog snapshot reflects on-disk state
+
+ // ─── Pre-snapshot assertions: DFA layout is correct ────────────────
+ assertDocCountInIndex(client, indexName, numDocs);
+
+ IndexShard shardBefore = getShardZero(indexName);
+ assertAllFormatDirsHaveFiles(shardBefore);
+ assertLuceneIndexDirContents(shardBefore);
+ // Catalog must match what's on local disk and what was uploaded to remote
+ DataFormatAwareITUtils.assertCatalogMatchesLocalAndRemote(shardBefore);
+ // Capture exhaustive pre-snapshot state for tight post-restore comparison
+ PreSnapshotState pre = capturePreSnapshotState(client, indexName, shardBefore);
+ Set filesBeforeSnapshot = captureShardFilesOnDisk(shardBefore);
+ Set catalogBefore = pre.catalogFilesExcludingSegments;
+
+ // ─── Take V2 snapshot ──────────────────────────────────────────────
+ // empty indices list triggers V2 path (cluster-manager only, pinned-timestamp based,
+ // no per-shard SnapshotShardsService dispatch)
+ logger.info("--> taking V2 snapshot");
+ SnapshotInfo snapshotInfo = createSnapshot(snapshotRepoName, snapshotName, new ArrayList<>());
+ assertThat(snapshotInfo.state(), equalTo(SnapshotState.SUCCESS));
+ assertEquals("single-shard index: snapshot must report exactly 1 successful shard", 1, snapshotInfo.successfulShards());
+ assertEquals("snapshot total shards must equal successful shards", snapshotInfo.successfulShards(), snapshotInfo.totalShards());
+ assertEquals("snapshot must contain exactly the DFA index", List.of(indexName), snapshotInfo.indices());
+
+ // Delete the index so we can restore from snapshot
+ assertAcked(client().admin().indices().delete(new DeleteIndexRequest(indexName)).get());
+ assertFalse(indexExists(indexName));
+
+ // ─── Restore from V2 snapshot ──────────────────────────────────────
+ logger.info("--> restoring from V2 snapshot");
+ RestoreSnapshotResponse restoreResponse = client.admin()
+ .cluster()
+ .prepareRestoreSnapshot(snapshotRepoName, snapshotName)
+ .setWaitForCompletion(true)
+ .setIndices(indexName)
+ .get();
+
+ assertEquals(RestStatus.OK, restoreResponse.status());
+ assertEquals(
+ "all shards must restore successfully",
+ restoreResponse.getRestoreInfo().totalShards(),
+ restoreResponse.getRestoreInfo().successfulShards()
+ );
+ assertEquals("no shards may fail to restore", 0, restoreResponse.getRestoreInfo().failedShards());
+
+ // ─── Post-restore assertions ───────────────────────────────────────
+ ensureYellowAndNoInitializingShards(indexName);
+ ensureGreen(indexName);
+ assertTrue(indexExists(indexName));
+
+ // Doc count restored — uses _stats API, NOT prepareSearch / prepareGet
+ assertDocCountInIndex(client, indexName, numDocs);
+
+ // DFA on-disk layout must be reconstructed identically: parquet/ dir + index/ (segments_N + lucene files)
+ IndexShard shardAfter = getShardZero(indexName);
+ // Tight post-restore validation: doc count, segment count, catalog files, generation, UUID, on-disk layout
+ assertRestoredIndexMatches(client, indexName, shardAfter, pre, /* requireSameUUID */ true);
+ // Verify the catalog files from before the snapshot are reconstructed on the restored shard's disk
+ Set filesAfterRestore = captureShardFilesOnDisk(shardAfter);
+ Set missingDataFiles = new HashSet<>();
+ for (String f : catalogBefore) {
+ if (filesAfterRestore.contains(f) == false && filesAfterRestore.contains("index/" + f) == false) {
+ missingDataFiles.add(f);
+ }
+ }
+ assertTrue(
+ "data files from snapshot must be restored to disk; missing: "
+ + missingDataFiles
+ + "\n before: "
+ + filesBeforeSnapshot
+ + "\n after: "
+ + filesAfterRestore,
+ missingDataFiles.isEmpty()
+ );
+ }
+
+ /**
+ * V2 snapshot of multiple DFA indices in a single snapshot, then restore all.
+ *
+ * Validates that the V2 cluster-manager-only flow snapshots ALL existing indices when
+ * the indices list is empty, and each index's per-format catalog/files are independently
+ * preserved and restored. Catches cross-index corruption (e.g., catalog files from index A
+ * leaking into index B's restore).
+ */
+ public void testV2MultiIndexSnapshotAndRestoreForDFA() throws Exception {
+ internalCluster().startClusterManagerOnlyNode();
+ internalCluster().startDataAndWarmNodes(1);
+ String indexA = "dfa-multi-a";
+ String indexB = "dfa-multi-b";
+ String snapshotRepoName = "test-snapshot-repo";
+ String snapshotName = "test-v2-multi-snapshot";
+ Path absolutePath1 = randomRepoPath().toAbsolutePath();
+ logger.info("Snapshot Path [{}]", absolutePath1);
+
+ createRepository(snapshotRepoName, "fs", getRepositorySettings(absolutePath1, true));
+
+ // Create two DFA indices with distinct doc counts so we can detect data crossover
+ Client client = client();
+ Settings indexSettings = getIndexSettings(1, 0).build();
+ createIndex(indexA, indexSettings);
+ createIndex(indexB, indexSettings);
+ ensureGreen(indexA, indexB);
+
+ final int numDocsA = 7;
+ final int numDocsB = 13;
+ indexDocuments(client, indexA, 0, numDocsA);
+ indexDocuments(client, indexB, 0, numDocsB);
+ refresh(indexA, indexB);
+ flush(indexA);
+ flush(indexB);
+
+ // Pre-snapshot DFA layout assertions on both
+ assertDocCountInIndex(client, indexA, numDocsA);
+ assertDocCountInIndex(client, indexB, numDocsB);
+ IndexShard shardABefore = getShardZero(indexA);
+ IndexShard shardBBefore = getShardZero(indexB);
+ assertAllFormatDirsHaveFiles(shardABefore);
+ assertAllFormatDirsHaveFiles(shardBBefore);
+ assertLuceneIndexDirContents(shardABefore);
+ assertLuceneIndexDirContents(shardBBefore);
+ DataFormatAwareITUtils.assertCatalogMatchesLocalAndRemote(shardABefore);
+ DataFormatAwareITUtils.assertCatalogMatchesLocalAndRemote(shardBBefore);
+
+ // Take V2 snapshot — empty indices list snapshots BOTH
+ logger.info("--> taking V2 snapshot of all DFA indices");
+ SnapshotInfo snapshotInfo = createSnapshot(snapshotRepoName, snapshotName, new ArrayList<>());
+ assertThat(snapshotInfo.state(), equalTo(SnapshotState.SUCCESS));
+ assertEquals("multi-index 1-shard each: snapshot must report exactly 2 successful shards", 2, snapshotInfo.successfulShards());
+ assertEquals("snapshot total shards must equal successful shards", snapshotInfo.successfulShards(), snapshotInfo.totalShards());
+ // V2 snapshot must contain BOTH indices
+ assertTrue("V2 snapshot must include " + indexA + ", got: " + snapshotInfo.indices(), snapshotInfo.indices().contains(indexA));
+ assertTrue("V2 snapshot must include " + indexB + ", got: " + snapshotInfo.indices(), snapshotInfo.indices().contains(indexB));
+
+ // Delete BOTH indices
+ assertAcked(client().admin().indices().delete(new DeleteIndexRequest(indexA, indexB)).get());
+ assertFalse(indexExists(indexA));
+ assertFalse(indexExists(indexB));
+
+ // Restore both from V2 snapshot
+ logger.info("--> restoring all indices from V2 snapshot");
+ RestoreSnapshotResponse restoreResponse = client.admin()
+ .cluster()
+ .prepareRestoreSnapshot(snapshotRepoName, snapshotName)
+ .setWaitForCompletion(true)
+ .setIndices(indexA, indexB)
+ .get();
+ assertEquals(RestStatus.OK, restoreResponse.status());
+ assertEquals(0, restoreResponse.getRestoreInfo().failedShards());
+
+ ensureGreen(indexA, indexB);
+
+ // Doc counts must be PRESERVED PER-INDEX (catches cross-index corruption)
+ assertDocCountInIndex(client, indexA, numDocsA);
+ assertDocCountInIndex(client, indexB, numDocsB);
+
+ // DFA layout intact for BOTH restored shards
+ IndexShard shardAAfter = getShardZero(indexA);
+ IndexShard shardBAfter = getShardZero(indexB);
+ assertAllFormatDirsHaveFiles(shardAAfter);
+ assertAllFormatDirsHaveFiles(shardBAfter);
+ assertLuceneIndexDirContents(shardAAfter);
+ assertLuceneIndexDirContents(shardBAfter);
+ DataFormatAwareITUtils.assertCatalogMatchesLocalAndRemote(shardAAfter);
+ DataFormatAwareITUtils.assertCatalogMatchesLocalAndRemote(shardBAfter);
+ }
+
+ /**
+ * Two sequential V2 snapshots with intervening writes; both must be independently restorable.
+ *
+ *
Validates V2 snapshot lifecycle for DFA:
+ *
+ * snap1 captures state at N docs, snap2 captures state at N+M docs
+ * Pinned-timestamp release for snap2 doesn't delete files still referenced by snap1
+ * Restore from snap1 yields exactly N docs (no stale snap2 files leak in)
+ * Restore from snap2 yields N+M docs (newer files present)
+ *
+ */
+ public void testV2IncrementalSnapshotsForDFA() throws Exception {
+ internalCluster().startClusterManagerOnlyNode();
+ internalCluster().startDataAndWarmNodes(1);
+ String indexName = "dfa-incremental-index";
+ String snapshotRepoName = "test-snapshot-repo";
+ String snap1 = "test-v2-snap1";
+ String snap2 = "test-v2-snap2";
+ Path absolutePath1 = randomRepoPath().toAbsolutePath();
+
+ createRepository(snapshotRepoName, "fs", getRepositorySettings(absolutePath1, true));
+
+ Client client = client();
+ Settings indexSettings = getIndexSettings(1, 0).build();
+ createIndex(indexName, indexSettings);
+ ensureGreen(indexName);
+
+ // Phase 1: index N docs, take snap1
+ final int docsBeforeSnap1 = 8;
+ indexDocuments(client, indexName, 0, docsBeforeSnap1);
+ refresh(indexName);
+ flush(indexName);
+ assertDocCountInIndex(client, indexName, docsBeforeSnap1);
+
+ logger.info("--> taking V2 snap1 at {} docs", docsBeforeSnap1);
+ SnapshotInfo snapInfo1 = createSnapshot(snapshotRepoName, snap1, new ArrayList<>());
+ assertThat(snapInfo1.state(), equalTo(SnapshotState.SUCCESS));
+
+ // Phase 2: index M more docs, take snap2
+ final int docsAfterSnap1 = 12;
+ final int totalDocs = docsBeforeSnap1 + docsAfterSnap1;
+ indexDocuments(client, indexName, docsBeforeSnap1, totalDocs);
+ refresh(indexName);
+ flush(indexName);
+ assertDocCountInIndex(client, indexName, totalDocs);
+
+ logger.info("--> taking V2 snap2 at {} docs", totalDocs);
+ SnapshotInfo snapInfo2 = createSnapshot(snapshotRepoName, snap2, new ArrayList<>());
+ assertThat(snapInfo2.state(), equalTo(SnapshotState.SUCCESS));
+
+ // Restore snap1 — must yield EXACTLY docsBeforeSnap1 (no leak from snap2)
+ logger.info("--> restoring V2 snap1");
+ assertAcked(client().admin().indices().delete(new DeleteIndexRequest(indexName)).get());
+ RestoreSnapshotResponse restore1 = client.admin()
+ .cluster()
+ .prepareRestoreSnapshot(snapshotRepoName, snap1)
+ .setWaitForCompletion(true)
+ .setIndices(indexName)
+ .get();
+ assertEquals(RestStatus.OK, restore1.status());
+ ensureGreen(indexName);
+ assertDocCountInIndex(client, indexName, docsBeforeSnap1);
+
+ IndexShard shardAfterSnap1 = getShardZero(indexName);
+ assertAllFormatDirsHaveFiles(shardAfterSnap1);
+ assertLuceneIndexDirContents(shardAfterSnap1);
+ DataFormatAwareITUtils.assertCatalogMatchesLocalAndRemote(shardAfterSnap1);
+
+ // Restore snap2 — must yield totalDocs (newer state)
+ logger.info("--> restoring V2 snap2");
+ assertAcked(client().admin().indices().delete(new DeleteIndexRequest(indexName)).get());
+ RestoreSnapshotResponse restore2 = client.admin()
+ .cluster()
+ .prepareRestoreSnapshot(snapshotRepoName, snap2)
+ .setWaitForCompletion(true)
+ .setIndices(indexName)
+ .get();
+ assertEquals(RestStatus.OK, restore2.status());
+ ensureGreen(indexName);
+ assertDocCountInIndex(client, indexName, totalDocs);
+
+ IndexShard shardAfterSnap2 = getShardZero(indexName);
+ assertAllFormatDirsHaveFiles(shardAfterSnap2);
+ assertLuceneIndexDirContents(shardAfterSnap2);
+ DataFormatAwareITUtils.assertCatalogMatchesLocalAndRemote(shardAfterSnap2);
+ }
+
+ /**
+ * Take a V2 snapshot of a DFA index, then delete the snapshot. Validates that:
+ *
+ * Snapshot creation succeeds
+ * Snapshot is listed by the snapshots API after creation
+ * {@code prepareDeleteSnapshot} succeeds (acknowledged)
+ * The snapshot is no longer listed after deletion
+ *
+ *
+ * This validates the V2 lifecycle: snapshot creation pins a timestamp and writes
+ * cluster-manager metadata; deletion must release that pin and clean up metadata.
+ * Mirrors {@code DeleteSnapshotV2IT.testRemoteStoreCleanupForDeletedIndexForSnapshotV2}
+ * but focuses on the snapshot-API contract (we don't probe internal blob counts here
+ * because remote-store cleanup is async and timing-sensitive).
+ */
+ public void testV2DeleteSnapshotForDFA() throws Exception {
+ internalCluster().startClusterManagerOnlyNode();
+ internalCluster().startDataAndWarmNodes(1);
+ String indexName = "dfa-delete-snapshot";
+ String snapshotRepoName = "test-snapshot-repo";
+ String snapshotName = "test-v2-delete";
+ Path absolutePath1 = randomRepoPath().toAbsolutePath();
+
+ createRepository(snapshotRepoName, "fs", getRepositorySettings(absolutePath1, true));
+
+ Client client = client();
+ Settings indexSettings = getIndexSettings(1, 0).build();
+ createIndex(indexName, indexSettings);
+ ensureGreen(indexName);
+
+ final int numDocs = 10;
+ indexDocuments(client, indexName, 0, numDocs);
+ refresh(indexName);
+ flush(indexName);
+
+ // Take V2 snapshot
+ logger.info("--> taking V2 snapshot for delete test");
+ SnapshotInfo snapshotInfo = createSnapshot(snapshotRepoName, snapshotName, new ArrayList<>());
+ assertThat(snapshotInfo.state(), equalTo(SnapshotState.SUCCESS));
+
+ // Verify snapshot is listed
+ List snapshotsBefore = client.admin().cluster().prepareGetSnapshots(snapshotRepoName).get().getSnapshots();
+ assertEquals("snapshot must be listed before deletion", 1, snapshotsBefore.size());
+ assertEquals(snapshotName, snapshotsBefore.get(0).snapshotId().getName());
+
+ // Delete the snapshot
+ logger.info("--> deleting V2 snapshot [{}]", snapshotName);
+ AcknowledgedResponse deleteResponse = client.admin().cluster().prepareDeleteSnapshot(snapshotRepoName, snapshotName).get();
+ assertAcked(deleteResponse);
+
+ // Verify snapshot is no longer listed
+ List snapshotsAfter = client.admin().cluster().prepareGetSnapshots(snapshotRepoName).get().getSnapshots();
+ assertTrue("snapshot must NOT be listed after deletion, got: " + snapshotsAfter, snapshotsAfter.isEmpty());
+
+ // The DFA index itself should still be healthy (snapshot deletion does not affect index)
+ ensureGreen(indexName);
+ assertDocCountInIndex(client, indexName, numDocs);
+ IndexShard shardAfterDelete = getShardZero(indexName);
+ assertAllFormatDirsHaveFiles(shardAfterDelete);
+ assertLuceneIndexDirContents(shardAfterDelete);
+ }
+
+ /**
+ * DR scenario: V2 snapshot a DFA index, delete the original index, then restore using
+ * alternate snapshot/segment/translog repositories that point at the same
+ * underlying file paths (read-only views).
+ *
+ * Validates that DFA's format-aware remote directory factory still resolves files
+ * correctly when the restore is configured with different repo names than were used
+ * at snapshot time. Mirrors {@code RestoreShallowSnapshotV2IT.testRestoreOperationsUsingDifferentRepos}.
+ */
+ public void testV2RestoreToDifferentRemoteRepoForDFA() throws Exception {
+ disableRepoConsistencyCheck("Remote store repo aliasing");
+ internalCluster().startClusterManagerOnlyNode();
+ internalCluster().startDataAndWarmNodes(1);
+ String indexName = "dfa-dr-index";
+ String snapshotRepoName = "test-snapshot-repo";
+ String snapshotName = "test-v2-dr-snapshot";
+ Path absolutePath1 = randomRepoPath().toAbsolutePath();
+
+ createRepository(snapshotRepoName, "fs", getRepositorySettings(absolutePath1, true));
+
+ Client client = client();
+ Settings indexSettings = getIndexSettings(1, 0).build();
+ createIndex(indexName, indexSettings);
+ ensureGreen(indexName);
+
+ final int numDocs = 15;
+ indexDocuments(client, indexName, 0, numDocs);
+ refresh(indexName);
+ flush(indexName);
+ assertDocCountInIndex(client, indexName, numDocs);
+
+ // Take V2 snapshot
+ logger.info("--> taking V2 snapshot for DR test");
+ SnapshotInfo snapshotInfo = createSnapshot(snapshotRepoName, snapshotName, new ArrayList<>());
+ assertThat(snapshotInfo.state(), equalTo(SnapshotState.SUCCESS));
+
+ // Create alternate repos pointing at the SAME underlying paths (read-only views)
+ // — simulates DR where the operator points a new cluster at the existing storage
+ String drSnapshotRepo = "dr-snapshot-repo";
+ String drSegmentRepo = "dr-segment-repo";
+ String drTranslogRepo = "dr-translog-repo";
+ createRepository(drSnapshotRepo, "fs", getRepositorySettings(snapshotRepoName, true));
+ createRepository(drSegmentRepo, "fs", getRepositorySettings(BASE_REMOTE_REPO, true));
+ createRepository(drTranslogRepo, "fs", getRepositorySettings(BASE_REMOTE_REPO, true));
+
+ // Delete the original index
+ assertAcked(client().admin().indices().delete(new DeleteIndexRequest(indexName)).get());
+ assertFalse(indexExists(indexName));
+
+ // Restore using the DR repos
+ logger.info("--> restoring V2 snapshot via DR repos");
+ RestoreSnapshotResponse restoreResponse = client.admin()
+ .cluster()
+ .prepareRestoreSnapshot(drSnapshotRepo, snapshotName)
+ .setWaitForCompletion(true)
+ .setIndices(indexName)
+ .setSourceRemoteStoreRepository(drSegmentRepo)
+ .setSourceRemoteTranslogRepository(drTranslogRepo)
+ .get();
+ assertEquals(RestStatus.OK, restoreResponse.status());
+ assertEquals(0, restoreResponse.getRestoreInfo().failedShards());
+
+ ensureGreen(indexName);
+ assertDocCountInIndex(client, indexName, numDocs);
+
+ IndexShard shardAfter = getShardZero(indexName);
+ assertAllFormatDirsHaveFiles(shardAfter);
+ assertLuceneIndexDirContents(shardAfter);
+ DataFormatAwareITUtils.assertCatalogMatchesLocalAndRemote(shardAfter);
+ }
+
+ /**
+ * V2 snapshot of a multi-shard DFA index. Validates that per-shard catalogs are
+ * independently preserved and restored.
+ *
+ *
Catches:
+ *
+ * Catalog files from one shard leaking into another shard's restored state
+ * Per-shard pinned-timestamp scoping issues
+ * Format-aware remote directory routing issues at multi-shard scale
+ *
+ */
+ public void testV2SnapshotWithMultipleShardsForDFA() throws Exception {
+ internalCluster().startClusterManagerOnlyNode();
+ internalCluster().startDataAndWarmNodes(2); // 2 data nodes so 3 shards distribute
+ String indexName = "dfa-multi-shard";
+ String snapshotRepoName = "test-snapshot-repo";
+ String snapshotName = "test-v2-multi-shard-snapshot";
+ Path absolutePath1 = randomRepoPath().toAbsolutePath();
+
+ createRepository(snapshotRepoName, "fs", getRepositorySettings(absolutePath1, true));
+
+ // 3 shards, 0 replicas
+ final int numShards = 3;
+ Client client = client();
+ Settings indexSettings = getIndexSettings(numShards, 0).build();
+ createIndex(indexName, indexSettings);
+ ensureGreen(indexName);
+
+ // Index a meaningful number of docs so each shard gets some
+ final int numDocs = 60;
+ indexDocuments(client, indexName, 0, numDocs);
+ refresh(indexName);
+ flush(indexName);
+ assertDocCountInIndex(client, indexName, numDocs);
+
+ // Pre-snapshot: every shard must have parquet+lucene layout
+ ClusterState clusterState = client.admin().cluster().prepareState().get().getState();
+ for (int shardId = 0; shardId < numShards; shardId++) {
+ String node = nodeNameForShard(clusterState, indexName, shardId);
+ IndexShard shard = getIndexShard(node, new ShardId(resolveIndex(indexName), shardId), indexName);
+ assertAllFormatDirsHaveFiles(shard);
+ assertLuceneIndexDirContents(shard);
+ DataFormatAwareITUtils.assertCatalogMatchesLocalAndRemote(shard);
+ }
+
+ // Take V2 snapshot
+ logger.info("--> taking V2 snapshot of multi-shard DFA index");
+ SnapshotInfo snapshotInfo = createSnapshot(snapshotRepoName, snapshotName, new ArrayList<>());
+ assertThat(snapshotInfo.state(), equalTo(SnapshotState.SUCCESS));
+ assertThat(snapshotInfo.successfulShards(), equalTo(numShards));
+ assertThat(snapshotInfo.totalShards(), equalTo(numShards));
+
+ // Delete and restore
+ assertAcked(client().admin().indices().delete(new DeleteIndexRequest(indexName)).get());
+ assertFalse(indexExists(indexName));
+
+ logger.info("--> restoring multi-shard V2 snapshot");
+ RestoreSnapshotResponse restoreResponse = client.admin()
+ .cluster()
+ .prepareRestoreSnapshot(snapshotRepoName, snapshotName)
+ .setWaitForCompletion(true)
+ .setIndices(indexName)
+ .get();
+ assertEquals(RestStatus.OK, restoreResponse.status());
+ assertEquals(numShards, restoreResponse.getRestoreInfo().totalShards());
+ assertEquals(numShards, restoreResponse.getRestoreInfo().successfulShards());
+ assertEquals(0, restoreResponse.getRestoreInfo().failedShards());
+
+ ensureGreen(indexName);
+
+ // Total doc count must match
+ assertDocCountInIndex(client, indexName, numDocs);
+
+ // EVERY restored shard must have intact DFA layout — catches per-shard corruption
+ ClusterState restoredState = client.admin().cluster().prepareState().get().getState();
+ for (int shardId = 0; shardId < numShards; shardId++) {
+ String node = nodeNameForShard(restoredState, indexName, shardId);
+ IndexShard shard = getIndexShard(node, new ShardId(resolveIndex(indexName), shardId), indexName);
+ assertAllFormatDirsHaveFiles(shard);
+ assertLuceneIndexDirContents(shard);
+ DataFormatAwareITUtils.assertCatalogMatchesLocalAndRemote(shard);
+ }
+ }
+
+ /**
+ * Resolve the node name hosting the primary of a given shard.
+ */
+ private String nodeNameForShard(ClusterState state, String indexName, int shardId) {
+ String nodeId = state.routingTable().index(indexName).shard(shardId).primaryShard().currentNodeId();
+ assertTrue("primary for shard " + shardId + " must be assigned", nodeId != null);
+ return state.nodes().get(nodeId).getName();
+ }
+
+ /**
+ * Snapshots the set of files present on a shard's local disk (parquet/, lucene index/, etc.)
+ * Used to verify file restoration after V2 restore.
+ */
+ private Set captureShardFilesOnDisk(IndexShard shard) throws IOException {
+ Set all = new HashSet<>();
+ Path indexDir = shard.shardPath().resolveIndex();
+ if (Files.exists(indexDir)) {
+ try (var stream = Files.list(indexDir)) {
+ stream.forEach(p -> all.add("index/" + p.getFileName()));
+ }
+ }
+ for (String format : expectedFormats()) {
+ if ("lucene".equals(format)) continue;
+ Path fmtDir = shard.shardPath().getDataPath().resolve(format);
+ if (Files.exists(fmtDir)) {
+ try (var stream = Files.list(fmtDir)) {
+ stream.forEach(p -> all.add(format + "/" + p.getFileName()));
+ }
+ }
+ }
+ return all;
+ }
+
+ /**
+ * Captured pre-snapshot state for tight post-restore comparison.
+ * Each field is exactly comparable to the post-restore state.
+ */
+ protected static final class PreSnapshotState {
+ final long docCount;
+ final int segmentCount;
+ final Set catalogFilesExcludingSegments;
+ final long catalogGeneration;
+ final String indexUUID;
+
+ PreSnapshotState(
+ long docCount,
+ int segmentCount,
+ Set catalogFilesExcludingSegments,
+ long catalogGeneration,
+ String indexUUID
+ ) {
+ this.docCount = docCount;
+ this.segmentCount = segmentCount;
+ this.catalogFilesExcludingSegments = catalogFilesExcludingSegments;
+ this.catalogGeneration = catalogGeneration;
+ this.indexUUID = indexUUID;
+ }
+ }
+
+ /** Capture exhaustive pre-snapshot state for tight post-restore comparison. */
+ protected PreSnapshotState capturePreSnapshotState(Client client, String indexName, IndexShard shard) throws IOException {
+ long docs = client.admin().indices().prepareStats(indexName).clear().setDocs(true).get().getIndex(indexName).getPrimaries().docs
+ .getCount();
+ int segs = countSegmentsOnShard(client, indexName);
+ Set catalogFiles = DataFormatAwareITUtils.catalogFilesExcludingSegments(shard);
+ long catalogGen;
+ try (
+ org.opensearch.common.concurrent.GatedCloseable ref = shard
+ .getCatalogSnapshot()
+ ) {
+ catalogGen = ref.get().getGeneration();
+ }
+ String uuid = client.admin().indices().prepareGetSettings(indexName).get().getSetting(indexName, IndexMetadata.SETTING_INDEX_UUID);
+ assertNotNull("pre-snapshot index UUID must be set", uuid);
+ assertFalse("pre-snapshot index UUID must be non-empty", uuid.isEmpty());
+ assertTrue("pre-snapshot catalog generation must be > 0, got " + catalogGen, catalogGen > 0L);
+ return new PreSnapshotState(docs, segs, catalogFiles, catalogGen, uuid);
+ }
+
+ /**
+ * Return number of segments on the primary of shard 0. Returns 0 if the segments API
+ * returns no info for the index (e.g., shard not yet ready or special engine state).
+ */
+ private int countSegmentsOnShard(Client client, String indexName) {
+ var resp = client.admin().indices().prepareSegments(indexName).get();
+ var indexSegs = resp.getIndices().get(indexName);
+ if (indexSegs == null) return 0;
+ var shardSegs = indexSegs.getShards().get(0);
+ if (shardSegs == null) return 0;
+ var iter = shardSegs.iterator();
+ if (iter.hasNext() == false) return 0;
+ return iter.next().getSegments().size();
+ }
+
+ /**
+ * Tight post-restore validation. Asserts the restored index matches the captured pre-snapshot
+ * state EXACTLY on doc count, segment count, and catalog file set; the catalog generation must
+ * be at least the pre-snapshot value (restore may bump it during finalize); and DFA on-disk
+ * layout is intact.
+ *
+ * @param requireSameUUID true if the restored index keeps the original UUID (typical case);
+ * false for rename tests where a new UUID is expected.
+ */
+ protected void assertRestoredIndexMatches(
+ Client client,
+ String restoredIndexName,
+ IndexShard restoredShard,
+ PreSnapshotState pre,
+ boolean requireSameUUID
+ ) throws IOException {
+ // Doc count must be EXACT
+ assertDocCountInIndex(client, restoredIndexName, pre.docCount);
+
+ // Segment count must be EXACT
+ int restoredSegCount = countSegmentsOnShard(client, restoredIndexName);
+ assertEquals(
+ "segment count must match pre-snapshot exactly: pre=" + pre.segmentCount + " post=" + restoredSegCount,
+ pre.segmentCount,
+ restoredSegCount
+ );
+
+ // Catalog files (excluding segments_N) must match EXACTLY
+ Set restoredCatalog = DataFormatAwareITUtils.catalogFilesExcludingSegments(restoredShard);
+ assertEquals(
+ "catalog file set must match pre-snapshot exactly: missing="
+ + diff(pre.catalogFilesExcludingSegments, restoredCatalog)
+ + " extra="
+ + diff(restoredCatalog, pre.catalogFilesExcludingSegments),
+ pre.catalogFilesExcludingSegments,
+ restoredCatalog
+ );
+
+ // Catalog generation must be >= pre-snapshot generation (restore may bump it)
+ long restoredGen;
+ try (
+ org.opensearch.common.concurrent.GatedCloseable ref = restoredShard
+ .getCatalogSnapshot()
+ ) {
+ restoredGen = ref.get().getGeneration();
+ }
+ assertTrue(
+ "restored catalog generation must be >= pre-snapshot: pre=" + pre.catalogGeneration + " post=" + restoredGen,
+ restoredGen >= pre.catalogGeneration
+ );
+
+ // UUID check: restore always creates a fresh index with a NEW UUID (not the snapshot's source UUID).
+ // For rename, the new UUID is also different. So in both cases the restored UUID must
+ // (a) be set, (b) differ from the pre-snapshot source UUID.
+ String restoredUuid = client.admin()
+ .indices()
+ .prepareGetSettings(restoredIndexName)
+ .get()
+ .getSetting(restoredIndexName, IndexMetadata.SETTING_INDEX_UUID);
+ assertNotNull("restored index UUID must be set", restoredUuid);
+ assertFalse("restored index UUID must be non-empty", restoredUuid.isEmpty());
+ assertNotEquals("restored index always has a fresh UUID, not the source UUID", pre.indexUUID, restoredUuid);
+
+ // DFA layout intact
+ assertAllFormatDirsHaveFiles(restoredShard);
+ assertLuceneIndexDirContents(restoredShard);
+ DataFormatAwareITUtils.assertCatalogMatchesLocalAndRemote(restoredShard);
+ }
+
+ /** Set difference helper for clearer failure messages. */
+ private static Set diff(Set a, Set b) {
+ Set r = new HashSet<>(a);
+ r.removeAll(b);
+ return r;
+ }
+
+ /**
+ * Asserts the catalog files (excluding segments_N) before snapshot are all present on
+ * the restored shard's disk. Validates the restore actually reconstructed the data files,
+ * not just metadata.
+ */
+ private void assertCatalogFilesRestoredOnDisk(IndexShard shardBefore, IndexShard shardAfter) throws IOException {
+ Set catalogBefore = DataFormatAwareITUtils.catalogFilesExcludingSegments(shardBefore);
+ Set filesAfter = captureShardFilesOnDisk(shardAfter);
+ assertFalse("catalog before snapshot must not be empty for shard " + shardBefore.routingEntry(), catalogBefore.isEmpty());
+ Set missing = new HashSet<>();
+ for (String catalogFile : catalogBefore) {
+ // catalog file names may already be format-prefixed (e.g. "parquet/foo.parquet")
+ if (filesAfter.contains(catalogFile) == false && filesAfter.contains("index/" + catalogFile) == false) {
+ missing.add(catalogFile);
+ }
+ }
+ assertTrue(
+ "catalog files missing from restored shard disk: "
+ + missing
+ + "\n catalog before: "
+ + catalogBefore
+ + "\n files on disk after restore: "
+ + filesAfter,
+ missing.isEmpty()
+ );
+ }
+
+ /**
+ * V2 snapshot + restore with different {@code RemoteStoreEnums.PathType} settings.
+ * Validates that DFA's format-aware remote directory routing works under HASHED_PREFIX,
+ * HASHED_INFIX, and FIXED path types.
+ *
+ * Mirrors {@code RestoreShallowSnapshotV2IT.testHashedPrefixTranslogMetadataCombination}.
+ */
+ public void testV2HashedPrefixPathTypeForDFA() throws Exception {
+ internalCluster().startClusterManagerOnlyNode();
+ internalCluster().startDataAndWarmNodes(1);
+
+ // Test all three path types in sequence.
+ // V2 allows only ONE repository with shallow_snapshot_v2=true cluster-wide, so we
+ // delete the repo between iterations.
+ for (RemoteStoreEnums.PathType pathType : RemoteStoreEnums.PathType.values()) {
+ String suffix = pathType.toString().toLowerCase(java.util.Locale.ROOT);
+ String indexName = "dfa-pathtype-" + suffix;
+ String snapshotRepoName = "snap-repo-" + suffix;
+ String snapshotName = "snap-" + suffix;
+ Path absolutePath = randomRepoPath().toAbsolutePath();
+
+ // Switch cluster path type
+ assertAcked(
+ client().admin()
+ .cluster()
+ .prepareUpdateSettings()
+ .setPersistentSettings(Settings.builder().put(CLUSTER_REMOTE_STORE_PATH_TYPE_SETTING.getKey(), pathType))
+ .get()
+ );
+
+ createRepository(snapshotRepoName, "fs", getRepositorySettings(absolutePath, true));
+ Client client = client();
+ createIndex(indexName, getIndexSettings(1, 0).build());
+ ensureGreen(indexName);
+
+ final int numDocs = 10;
+ indexDocuments(client, indexName, 0, numDocs);
+ refresh(indexName);
+ flush(indexName);
+ assertDocCountInIndex(client, indexName, numDocs);
+
+ IndexShard shardBefore = getShardZero(indexName);
+ assertAllFormatDirsHaveFiles(shardBefore);
+ assertLuceneIndexDirContents(shardBefore);
+
+ logger.info("--> taking V2 snapshot for pathType={}", pathType);
+ SnapshotInfo snapshotInfo = createSnapshot(snapshotRepoName, snapshotName, new ArrayList<>());
+ assertThat("pathType=" + pathType, snapshotInfo.state(), equalTo(SnapshotState.SUCCESS));
+ assertEquals("pathType=" + pathType + " single-shard exactly 1 successful", 1, snapshotInfo.successfulShards());
+
+ assertAcked(client().admin().indices().delete(new DeleteIndexRequest(indexName)).get());
+ assertFalse("pathType=" + pathType, indexExists(indexName));
+
+ logger.info("--> restoring V2 snapshot for pathType={}", pathType);
+ RestoreSnapshotResponse restoreResponse = client.admin()
+ .cluster()
+ .prepareRestoreSnapshot(snapshotRepoName, snapshotName)
+ .setWaitForCompletion(true)
+ .setIndices(indexName)
+ .get();
+ assertEquals("pathType=" + pathType, RestStatus.OK, restoreResponse.status());
+ assertEquals("pathType=" + pathType, 0, restoreResponse.getRestoreInfo().failedShards());
+
+ ensureGreen(indexName);
+ assertDocCountInIndex(client, indexName, numDocs);
+
+ IndexShard shardAfter = getShardZero(indexName);
+ assertAllFormatDirsHaveFiles(shardAfter);
+ assertLuceneIndexDirContents(shardAfter);
+ DataFormatAwareITUtils.assertCatalogMatchesLocalAndRemote(shardAfter);
+
+ // Clean up so the next iteration can register a new repo with shallow_snapshot_v2=true
+ // (V2 allows only ONE such repo cluster-wide).
+ assertAcked(client().admin().indices().delete(new DeleteIndexRequest(indexName)).get());
+ assertAcked(client().admin().cluster().prepareDeleteSnapshot(snapshotRepoName, snapshotName).get());
+ assertAcked(client().admin().cluster().prepareDeleteRepository(snapshotRepoName).get());
+ }
+ }
+
+ /**
+ * V2 snapshot + clone the snapshot + restore from clone for a DFA index.
+ * Validates pinned-timestamp inheritance through clones.
+ *
+ *
Mirrors {@code CloneSnapshotV2IT.testCloneShallowCopyV2}.
+ */
+ public void testV2CloneSnapshotForDFA() throws Exception {
+ internalCluster().startClusterManagerOnlyNode();
+ internalCluster().startDataAndWarmNodes(1);
+ String indexName = "dfa-clone-snapshot";
+ String snapshotRepoName = "test-clone-repo";
+ String sourceSnapshot = "snap-source";
+ String clonedSnapshot = "snap-cloned";
+ Path absolutePath1 = randomRepoPath().toAbsolutePath();
+
+ createRepository(snapshotRepoName, "fs", getRepositorySettings(absolutePath1, true));
+
+ Client client = client();
+ Settings indexSettings = getIndexSettings(1, 0).build();
+ createIndex(indexName, indexSettings);
+ ensureGreen(indexName);
+
+ final int numDocs = 12;
+ indexDocuments(client, indexName, 0, numDocs);
+ refresh(indexName);
+ flush(indexName);
+ assertDocCountInIndex(client, indexName, numDocs);
+
+ // Take source V2 snapshot
+ logger.info("--> taking source V2 snapshot");
+ SnapshotInfo source = createSnapshot(snapshotRepoName, sourceSnapshot, new ArrayList<>());
+ assertThat(source.state(), equalTo(SnapshotState.SUCCESS));
+
+ // Clone the snapshot — V2 requires wildcard pattern "*" for indices
+ logger.info("--> cloning V2 snapshot");
+ AcknowledgedResponse cloneResponse = client.admin()
+ .cluster()
+ .prepareCloneSnapshot(snapshotRepoName, sourceSnapshot, clonedSnapshot)
+ .setIndices("*")
+ .get();
+ assertAcked(cloneResponse);
+
+ // Verify both snapshots exist in the repo
+ List snapshots = client.admin().cluster().prepareGetSnapshots(snapshotRepoName).get().getSnapshots();
+ Set snapshotNames = new HashSet<>();
+ for (SnapshotInfo s : snapshots) {
+ snapshotNames.add(s.snapshotId().getName());
+ }
+ assertTrue("source snapshot must be listed: " + snapshotNames, snapshotNames.contains(sourceSnapshot));
+ assertTrue("cloned snapshot must be listed: " + snapshotNames, snapshotNames.contains(clonedSnapshot));
+
+ // Delete the source index and restore from CLONE
+ assertAcked(client().admin().indices().delete(new DeleteIndexRequest(indexName)).get());
+ assertFalse(indexExists(indexName));
+
+ logger.info("--> restoring from cloned V2 snapshot");
+ RestoreSnapshotResponse restoreResponse = client.admin()
+ .cluster()
+ .prepareRestoreSnapshot(snapshotRepoName, clonedSnapshot)
+ .setWaitForCompletion(true)
+ .setIndices(indexName)
+ .get();
+ assertEquals(RestStatus.OK, restoreResponse.status());
+ assertEquals(0, restoreResponse.getRestoreInfo().failedShards());
+
+ ensureGreen(indexName);
+ assertDocCountInIndex(client, indexName, numDocs);
+
+ IndexShard shardAfter = getShardZero(indexName);
+ assertAllFormatDirsHaveFiles(shardAfter);
+ assertLuceneIndexDirContents(shardAfter);
+ DataFormatAwareITUtils.assertCatalogMatchesLocalAndRemote(shardAfter);
+ }
+
+ /**
+ * Two V2 snapshots fired in rapid sequence on the same repository. V2 design serializes
+ * snapshots within a single repo (only one snapshot can be in finalization state at a time),
+ * and only ONE repo cluster-wide can have {@code shallow_snapshot_v2=true}. So the test
+ * fires snapshots back-to-back with no delay and verifies both succeed without races.
+ *
+ * Validates DFA refresh/flush concurrency under V2 timestamp-pinning and that both
+ * snapshots are independently restorable.
+ */
+ public void testV2ConcurrentSnapshotsForDFA() throws Exception {
+ internalCluster().startClusterManagerOnlyNode();
+ internalCluster().startDataAndWarmNodes(1);
+ String indexName = "dfa-rapid-snap";
+ String snapshotRepoName = "test-rapid-repo";
+ String snap1 = "rapid-snap-1";
+ String snap2 = "rapid-snap-2";
+ Path absolutePath1 = randomRepoPath().toAbsolutePath();
+
+ createRepository(snapshotRepoName, "fs", getRepositorySettings(absolutePath1, true));
+
+ Client client = client();
+ Settings indexSettings = getIndexSettings(1, 0).build();
+ createIndex(indexName, indexSettings);
+ ensureGreen(indexName);
+
+ final int numDocs = 15;
+ indexDocuments(client, indexName, 0, numDocs);
+ refresh(indexName);
+ flush(indexName);
+ assertDocCountInIndex(client, indexName, numDocs);
+
+ // Fire two V2 snapshots back-to-back on the same repo
+ logger.info("--> taking back-to-back V2 snapshots");
+ SnapshotInfo info1 = createSnapshot(snapshotRepoName, snap1, new ArrayList<>());
+ SnapshotInfo info2 = createSnapshot(snapshotRepoName, snap2, new ArrayList<>());
+
+ // Both must succeed
+ assertThat("snap1 state", info1.state(), equalTo(SnapshotState.SUCCESS));
+ assertThat("snap2 state", info2.state(), equalTo(SnapshotState.SUCCESS));
+ assertEquals("snap1 single-shard exactly 1 successful", 1, info1.successfulShards());
+ assertEquals("snap2 single-shard exactly 1 successful", 1, info2.successfulShards());
+
+ // Restore from each, verify both work
+ for (String snap : new String[] { snap1, snap2 }) {
+ logger.info("--> restoring rapid V2 snapshot [{}]", snap);
+ assertAcked(client().admin().indices().delete(new DeleteIndexRequest(indexName)).get());
+
+ RestoreSnapshotResponse restoreResponse = client.admin()
+ .cluster()
+ .prepareRestoreSnapshot(snapshotRepoName, snap)
+ .setWaitForCompletion(true)
+ .setIndices(indexName)
+ .get();
+ assertEquals("snap=" + snap, RestStatus.OK, restoreResponse.status());
+ assertEquals("snap=" + snap, 0, restoreResponse.getRestoreInfo().failedShards());
+
+ ensureGreen(indexName);
+ assertDocCountInIndex(client, indexName, numDocs);
+
+ IndexShard shardAfter = getShardZero(indexName);
+ assertAllFormatDirsHaveFiles(shardAfter);
+ assertLuceneIndexDirContents(shardAfter);
+ DataFormatAwareITUtils.assertCatalogMatchesLocalAndRemote(shardAfter);
+ }
+ }
+
+ /**
+ * V2 restore with rename pattern — the snapshot was taken on {@code source-idx} but the
+ * restored index gets a new name {@code restored-idx}. Validates DFA handles the renamed
+ * index UUID correctly (file paths, catalog, remote directory routing all use the new UUID).
+ *
+ *
Mirrors the rename pattern from
+ * {@code RestoreShallowSnapshotV2IT.testRestoreOperationsShallowCopyEnabled}.
+ */
+ public void testV2RestoreWithRenameForDFA() throws Exception {
+ internalCluster().startClusterManagerOnlyNode();
+ internalCluster().startDataAndWarmNodes(1);
+ String sourceIndex = "dfa-rename-source";
+ String renamedIndex = "dfa-rename-restored";
+ String snapshotRepoName = "test-rename-repo";
+ String snapshotName = "snap-rename";
+ Path absolutePath1 = randomRepoPath().toAbsolutePath();
+
+ createRepository(snapshotRepoName, "fs", getRepositorySettings(absolutePath1, true));
+
+ Client client = client();
+ Settings indexSettings = getIndexSettings(1, 0).build();
+ createIndex(sourceIndex, indexSettings);
+ ensureGreen(sourceIndex);
+
+ final int numDocs = 10;
+ indexDocuments(client, sourceIndex, 0, numDocs);
+ refresh(sourceIndex);
+ flush(sourceIndex);
+ assertDocCountInIndex(client, sourceIndex, numDocs);
+
+ // Capture pre-snapshot state for tight post-restore validation
+ IndexShard sourceShard = getShardZero(sourceIndex);
+ PreSnapshotState pre = capturePreSnapshotState(client, sourceIndex, sourceShard);
+
+ logger.info("--> taking V2 snapshot of source");
+ SnapshotInfo snapshotInfo = createSnapshot(snapshotRepoName, snapshotName, new ArrayList<>());
+ assertThat(snapshotInfo.state(), equalTo(SnapshotState.SUCCESS));
+ assertEquals("rename: snapshot has exactly 1 shard", 1, snapshotInfo.successfulShards());
+
+ // Source index can stay; we rename on restore
+ logger.info("--> restoring with rename: {} -> {}", sourceIndex, renamedIndex);
+ RestoreSnapshotResponse restoreResponse = client.admin()
+ .cluster()
+ .prepareRestoreSnapshot(snapshotRepoName, snapshotName)
+ .setWaitForCompletion(true)
+ .setIndices(sourceIndex)
+ .setRenamePattern(sourceIndex)
+ .setRenameReplacement(renamedIndex)
+ .get();
+ assertEquals(RestStatus.OK, restoreResponse.status());
+ assertEquals(0, restoreResponse.getRestoreInfo().failedShards());
+
+ ensureGreen(renamedIndex);
+ // Renamed index must exist and have all docs; original is unchanged
+ assertTrue("renamed index must exist", indexExists(renamedIndex));
+ assertTrue("source index must still exist", indexExists(sourceIndex));
+ assertDocCountInIndex(client, sourceIndex, numDocs);
+
+ // Tight validation: renamed index gets a NEW UUID but matches source on docs/segments/catalog
+ IndexShard renamedShard = getShardZero(renamedIndex);
+ assertRestoredIndexMatches(client, renamedIndex, renamedShard, pre, /* requireSameUUID */ false);
+ }
+
+ /**
+ * V2 restore with overridden index settings. Some settings are restorable-mutable
+ * (e.g., {@code refresh_interval}). Validates the override is applied to the restored index.
+ */
+ public void testV2RestoreWithUpdatedSettingsForDFA() throws Exception {
+ internalCluster().startClusterManagerOnlyNode();
+ internalCluster().startDataAndWarmNodes(1);
+ String indexName = "dfa-settings-restore";
+ String snapshotRepoName = "test-settings-repo";
+ String snapshotName = "snap-settings";
+ Path absolutePath1 = randomRepoPath().toAbsolutePath();
+
+ createRepository(snapshotRepoName, "fs", getRepositorySettings(absolutePath1, true));
+
+ Client client = client();
+ // Original index uses 300s refresh
+ Settings indexSettings = getIndexSettings(1, 0).put(IndexSettings.INDEX_REFRESH_INTERVAL_SETTING.getKey(), "300s").build();
+ createIndex(indexName, indexSettings);
+ ensureGreen(indexName);
+
+ final int numDocs = 10;
+ indexDocuments(client, indexName, 0, numDocs);
+ refresh(indexName);
+ flush(indexName);
+
+ // Take V2 snapshot
+ SnapshotInfo snapshotInfo = createSnapshot(snapshotRepoName, snapshotName, new ArrayList<>());
+ assertThat(snapshotInfo.state(), equalTo(SnapshotState.SUCCESS));
+
+ // Delete and restore with overridden refresh_interval = 5s
+ assertAcked(client().admin().indices().delete(new DeleteIndexRequest(indexName)).get());
+ Settings overriddenSettings = Settings.builder().put(IndexSettings.INDEX_REFRESH_INTERVAL_SETTING.getKey(), "5s").build();
+
+ logger.info("--> restoring V2 snapshot with overridden refresh_interval=5s");
+ RestoreSnapshotResponse restoreResponse = client.admin()
+ .cluster()
+ .prepareRestoreSnapshot(snapshotRepoName, snapshotName)
+ .setWaitForCompletion(true)
+ .setIndices(indexName)
+ .setIndexSettings(overriddenSettings)
+ .setIgnoreIndexSettings(IndexSettings.INDEX_REFRESH_INTERVAL_SETTING.getKey())
+ .get();
+ assertEquals(RestStatus.OK, restoreResponse.status());
+ ensureGreen(indexName);
+ assertDocCountInIndex(client, indexName, numDocs);
+
+ // Verify the setting override took effect
+ String actualRefreshInterval = client.admin()
+ .indices()
+ .prepareGetSettings(indexName)
+ .get()
+ .getSetting(indexName, IndexSettings.INDEX_REFRESH_INTERVAL_SETTING.getKey());
+ assertEquals("refresh_interval override must take effect on restored index", "5s", actualRefreshInterval);
+
+ // DFA layout intact
+ IndexShard shardAfter = getShardZero(indexName);
+ assertAllFormatDirsHaveFiles(shardAfter);
+ assertLuceneIndexDirContents(shardAfter);
+ DataFormatAwareITUtils.assertCatalogMatchesLocalAndRemote(shardAfter);
+ }
+
+ /**
+ * V2 snapshot of a DFA index with zero documents . Edge case — does V2 handle
+ * the "empty catalog" case correctly?
+ *
+ *
The index is created and immediately snapshotted (no indexing, no flush). The snapshot
+ * must succeed; restore must produce a green, empty index with zero docs.
+ */
+ public void testV2EmptyIndexSnapshot() throws Exception {
+ internalCluster().startClusterManagerOnlyNode();
+ internalCluster().startDataAndWarmNodes(1);
+ String indexName = "dfa-empty-index";
+ String snapshotRepoName = "test-empty-repo";
+ String snapshotName = "snap-empty";
+ Path absolutePath1 = randomRepoPath().toAbsolutePath();
+
+ createRepository(snapshotRepoName, "fs", getRepositorySettings(absolutePath1, true));
+
+ Client client = client();
+ Settings indexSettings = getIndexSettings(1, 0).build();
+ createIndex(indexName, indexSettings);
+ ensureGreen(indexName);
+
+ // No documents indexed. Force a flush to materialize an empty commit point.
+ flush(indexName);
+
+ // Pre-snapshot: 0 docs
+ assertDocCountInIndex(client, indexName, 0);
+
+ // Take V2 snapshot of empty index
+ logger.info("--> taking V2 snapshot of empty DFA index");
+ SnapshotInfo snapshotInfo = createSnapshot(snapshotRepoName, snapshotName, new ArrayList<>());
+ assertThat(snapshotInfo.state(), equalTo(SnapshotState.SUCCESS));
+ // Even an empty index has 1 shard reported
+ assertEquals("empty index 1-shard exactly 1 successful", 1, snapshotInfo.successfulShards());
+ assertEquals(0, snapshotInfo.failedShards());
+
+ // Delete and restore
+ assertAcked(client().admin().indices().delete(new DeleteIndexRequest(indexName)).get());
+ assertFalse(indexExists(indexName));
+
+ logger.info("--> restoring empty V2 snapshot");
+ RestoreSnapshotResponse restoreResponse = client.admin()
+ .cluster()
+ .prepareRestoreSnapshot(snapshotRepoName, snapshotName)
+ .setWaitForCompletion(true)
+ .setIndices(indexName)
+ .get();
+ assertEquals(RestStatus.OK, restoreResponse.status());
+ assertEquals(0, restoreResponse.getRestoreInfo().failedShards());
+
+ ensureGreen(indexName);
+ assertDocCountInIndex(client, indexName, 0);
+
+ // After restore, the index should be writable — index a doc to confirm
+ indexDocuments(client, indexName, 0, 1);
+ refresh(indexName);
+ assertDocCountInIndex(client, indexName, 1);
+ }
+
+ /**
+ * Validates the {@code _snapshot/_status} API works for V2 DFA snapshots. The status API
+ * is a different code path than create/restore and may have its own bugs that don't
+ * surface in the create/restore flow.
+ */
+ public void testV2SnapshotStatusAPIForDFA() throws Exception {
+ internalCluster().startClusterManagerOnlyNode();
+ internalCluster().startDataAndWarmNodes(1);
+ String indexName = "dfa-status-api";
+ String snapshotRepoName = "test-status-repo";
+ String snapshotName = "snap-status";
+ Path absolutePath1 = randomRepoPath().toAbsolutePath();
+
+ createRepository(snapshotRepoName, "fs", getRepositorySettings(absolutePath1, true));
+
+ Client client = client();
+ Settings indexSettings = getIndexSettings(1, 0).build();
+ createIndex(indexName, indexSettings);
+ ensureGreen(indexName);
+
+ final int numDocs = 10;
+ indexDocuments(client, indexName, 0, numDocs);
+ refresh(indexName);
+ flush(indexName);
+
+ // Take V2 snapshot
+ SnapshotInfo snapshotInfo = createSnapshot(snapshotRepoName, snapshotName, new ArrayList<>());
+ assertThat(snapshotInfo.state(), equalTo(SnapshotState.SUCCESS));
+
+ // Call _snapshot/_status API
+ org.opensearch.action.admin.cluster.snapshots.status.SnapshotsStatusResponse statusResponse = client.admin()
+ .cluster()
+ .prepareSnapshotStatus(snapshotRepoName)
+ .setSnapshots(snapshotName)
+ .get();
+
+ assertEquals("status API must return one snapshot", 1, statusResponse.getSnapshots().size());
+ org.opensearch.action.admin.cluster.snapshots.status.SnapshotStatus status = statusResponse.getSnapshots().get(0);
+ assertEquals("snapshot name in status must match", snapshotName, status.getSnapshot().getSnapshotId().getName());
+ assertNotNull("status object must contain shard stats", status.getShardsStats());
+ // Single-shard index — exact assertions
+ assertEquals("status totalShards must be 1", 1, status.getShardsStats().getTotalShards());
+ assertEquals("status doneShards must be 1", 1, status.getShardsStats().getDoneShards());
+ assertEquals("status failedShards must be 0", 0, status.getShardsStats().getFailedShards());
+ assertEquals("status initializingShards must be 0", 0, status.getShardsStats().getInitializingShards());
+ // status must contain index stats for our index
+ assertEquals("status must list the DFA index", 1, status.getIndices().size());
+ assertTrue("status must contain the DFA index, got: " + status.getIndices().keySet(), status.getIndices().containsKey(indexName));
+ }
+
+ /**
+ * Wait until the pinned-timestamp scheduler has fired at least once after invocation.
+ * Mirrors {@code DeleteSnapshotV2IT.keepPinnedTimestampSchedulerUpdated} but uses
+ * {@code assertBusy} to avoid the awaitility dependency (not on classpath here).
+ */
+ private void keepPinnedTimestampSchedulerUpdated() throws Exception {
+ long currentTime = System.currentTimeMillis();
+ assertBusy(
+ () -> assertTrue(
+ "pinned-timestamp scheduler should fire after invocation",
+ RemoteStorePinnedTimestampService.getPinnedTimestamps().v1() > currentTime
+ ),
+ 10,
+ TimeUnit.SECONDS
+ );
+ }
+
+ /**
+ * DFA-specific test: verify that ALL format directories ({@code segments/},
+ * {@code translog/}, and any DFA-specific format dirs) are cleaned up after V2 snapshot
+ * deletion + index deletion + lookback expiry. Lucene-only tests check just {@code segments/}
+ * and {@code translog/}; for DFA there could be additional format files that bug-prone
+ * cleanup logic might miss.
+ */
+ public void testV2DeleteSnapshotCleansUpAllFormatFilesForDFA() throws Exception {
+ internalCluster().startClusterManagerOnlyNode();
+ internalCluster().startDataAndWarmNodes(1);
+ String indexName = "dfa-cleanup-index";
+ String snapshotRepoName = "test-cleanup-repo";
+ String snapshotName = "snap-cleanup";
+ Path absolutePath1 = randomRepoPath().toAbsolutePath();
+
+ createRepository(snapshotRepoName, "fs", getRepositorySettings(absolutePath1, true));
+
+ Client client = client();
+ Settings indexSettings = getIndexSettings(1, 0).build();
+ createIndex(indexName, indexSettings);
+ ensureGreen(indexName);
+
+ final int numDocs = 25;
+ indexDocuments(client, indexName, 0, numDocs);
+ refresh(indexName);
+ flush(indexName);
+
+ // Get index UUID — used to find the right shard subtree under the remote-store layout
+ // (the actual path includes a path-type prefix like HASHED_PREFIX so we walk the tree).
+ String indexUUID = client.admin()
+ .indices()
+ .prepareGetSettings(indexName)
+ .get()
+ .getSetting(indexName, IndexMetadata.SETTING_INDEX_UUID);
+
+ // Take V2 snapshot
+ SnapshotInfo snapshotInfo = createSnapshot(snapshotRepoName, snapshotName, new ArrayList<>());
+ assertThat(snapshotInfo.state(), equalTo(SnapshotState.SUCCESS));
+
+ // Pre-delete: remote store must have segment + translog files under the indexUUID subtree.
+ long segmentFilesBefore = countFilesUnder(remoteRepoPath, indexUUID, "segments");
+ long translogFilesBefore = countFilesUnder(remoteRepoPath, indexUUID, "translog");
+ assertTrue("segment files must exist for index " + indexUUID + " before delete, got " + segmentFilesBefore, segmentFilesBefore > 0);
+ assertTrue(
+ "translog files must exist for index " + indexUUID + " before delete, got " + translogFilesBefore,
+ translogFilesBefore > 0
+ );
+
+ // Configure aggressive pinned-timestamp release so cleanup can fire promptly
+ String clusterManagerName = internalCluster().getClusterManagerName();
+ RemoteStorePinnedTimestampService remoteStorePinnedTimestampService = internalCluster().getInstance(
+ RemoteStorePinnedTimestampService.class,
+ clusterManagerName
+ );
+ RemoteStoreSettings.setPinnedTimestampsLookbackInterval(TimeValue.ZERO);
+ remoteStorePinnedTimestampService.rescheduleAsyncUpdatePinnedTimestampTask(TimeValue.timeValueSeconds(1));
+ keepPinnedTimestampSchedulerUpdated();
+
+ // Delete the index AND the snapshot
+ assertAcked(client().admin().indices().prepareDelete(indexName));
+ AcknowledgedResponse deleteResponse = client.admin().cluster().prepareDeleteSnapshot(snapshotRepoName, snapshotName).get();
+ assertAcked(deleteResponse);
+
+ // Wait for cleanup to drain ALL format directories to zero for this index UUID.
+ // For DFA this MUST include any per-format dirs; we walk the entire indexUUID subtree.
+ // 120s timeout because pinned-timestamp lookback + scheduler iterations can take a while
+ // for DFA's per-format file lifecycle.
+ assertBusy(() -> {
+ long segCount = countFilesUnder(remoteRepoPath, indexUUID, "segments");
+ long translogCount = countFilesUnder(remoteRepoPath, indexUUID, "translog");
+ assertEquals("segments/ subtree must be cleaned up for indexUUID=" + indexUUID, 0, segCount);
+ assertEquals("translog/ subtree must be cleaned up for indexUUID=" + indexUUID, 0, translogCount);
+ }, 120, TimeUnit.SECONDS);
+ }
+
+ /**
+ * Walk the entire {@code rootPath} subtree and count files whose path contains the given
+ * indexUUID and the given category (e.g. {@code "segments"} or {@code "translog"}).
+ * Path-type-agnostic: works with FIXED, HASHED_PREFIX, and HASHED_INFIX layouts.
+ */
+ private static long countFilesUnder(Path rootPath, String indexUUID, String categoryDirName) throws IOException {
+ if (Files.exists(rootPath) == false) return 0;
+ long[] count = { 0 };
+ Files.walkFileTree(rootPath, new java.nio.file.SimpleFileVisitor<>() {
+ @Override
+ public java.nio.file.FileVisitResult visitFile(Path file, java.nio.file.attribute.BasicFileAttributes attrs) {
+ // Iterate path name elements rather than substring-matching the toString() — the latter
+ // breaks on Windows where the separator is '\\', and also matches non-component substrings.
+ boolean hasUuid = false;
+ boolean hasCategory = false;
+ for (Path part : file) {
+ String name = part.toString();
+ if (indexUUID.equals(name)) {
+ hasUuid = true;
+ } else if (categoryDirName.equals(name)) {
+ hasCategory = true;
+ }
+ }
+ if (hasUuid && hasCategory) {
+ count[0]++;
+ }
+ return java.nio.file.FileVisitResult.CONTINUE;
+ }
+
+ @Override
+ public java.nio.file.FileVisitResult visitFileFailed(Path file, IOException exc) {
+ // Concurrent cleanup may delete a file between enumeration and visit; tolerate.
+ return java.nio.file.FileVisitResult.CONTINUE;
+ }
+ });
+ return count[0];
+ }
+
+ /**
+ * DFA-specific test: verifies that the DFA catalog generation is preserved
+ * (or advanced) when a V2 snapshot is cloned and restored from the clone. Catalog generation
+ * is a DFA-specific monotonic counter not present in Lucene-only engines.
+ */
+ public void testV2CloneSnapshotPreservesCatalogGenerationForDFA() throws Exception {
+ internalCluster().startClusterManagerOnlyNode();
+ internalCluster().startDataAndWarmNodes(1);
+ String indexName = "dfa-clone-catalog-gen";
+ String snapshotRepoName = "test-clone-gen-repo";
+ String sourceSnapshot = "src-snap";
+ String clonedSnapshot = "cloned-snap";
+ Path absolutePath1 = randomRepoPath().toAbsolutePath();
+
+ createRepository(snapshotRepoName, "fs", getRepositorySettings(absolutePath1, true));
+
+ Client client = client();
+ Settings indexSettings = getIndexSettings(1, 0).build();
+ createIndex(indexName, indexSettings);
+ ensureGreen(indexName);
+
+ final int numDocs = 12;
+ indexDocuments(client, indexName, 0, numDocs);
+ refresh(indexName);
+ flush(indexName);
+
+ IndexShard sourceShard = getShardZero(indexName);
+ long sourceGen;
+ try (
+ org.opensearch.common.concurrent.GatedCloseable ref = sourceShard
+ .getCatalogSnapshot()
+ ) {
+ sourceGen = ref.get().getGeneration();
+ }
+ assertTrue("pre-snapshot catalog generation must be > 0", sourceGen > 0L);
+
+ SnapshotInfo source = createSnapshot(snapshotRepoName, sourceSnapshot, new ArrayList<>());
+ assertThat(source.state(), equalTo(SnapshotState.SUCCESS));
+
+ AcknowledgedResponse cloneResponse = client.admin()
+ .cluster()
+ .prepareCloneSnapshot(snapshotRepoName, sourceSnapshot, clonedSnapshot)
+ .setIndices("*")
+ .get();
+ assertAcked(cloneResponse);
+
+ assertAcked(client().admin().indices().delete(new DeleteIndexRequest(indexName)).get());
+ RestoreSnapshotResponse restoreResponse = client.admin()
+ .cluster()
+ .prepareRestoreSnapshot(snapshotRepoName, clonedSnapshot)
+ .setWaitForCompletion(true)
+ .setIndices(indexName)
+ .get();
+ assertEquals(RestStatus.OK, restoreResponse.status());
+ ensureGreen(indexName);
+
+ IndexShard restoredShard = getShardZero(indexName);
+ long restoredGen;
+ try (
+ org.opensearch.common.concurrent.GatedCloseable ref = restoredShard
+ .getCatalogSnapshot()
+ ) {
+ restoredGen = ref.get().getGeneration();
+ }
+ assertTrue(
+ "DFA catalog generation must be monotonic across clone+restore: source=" + sourceGen + " restored=" + restoredGen,
+ restoredGen >= sourceGen
+ );
+ }
+
+ /**
+ * DFA-specific test: validates that Lucene segments' DFA-specific
+ * {@code writer_generation} attribute survives V2 snapshot/restore. This attribute is
+ * stamped by {@link org.opensearch.be.lucene.index.LuceneWriterCodec} during initial
+ * indexing and is required by DFA to correlate Lucene segments with the corresponding
+ * parquet files. If snapshot/restore drops this attribute, DFA can't reconstruct the
+ * format mapping.
+ */
+ public void testV2SnapshotPreservesWriterGenerationAttributeForDFA() throws Exception {
+ // The writer_generation attribute is stamped by LuceneWriterCodec only when Lucene is
+ // a secondary format. For parquet-only configurations there are no Lucene segments
+ // with data, hence no writer_generation attributes to validate.
+ org.junit.Assume.assumeTrue("writer_generation attribute test requires Lucene secondary format", hasLuceneSecondary());
+ internalCluster().startClusterManagerOnlyNode();
+ internalCluster().startDataAndWarmNodes(1);
+ String indexName = "dfa-writergen-attr";
+ String snapshotRepoName = "test-writergen-repo";
+ String snapshotName = "snap-writergen";
+ Path absolutePath1 = randomRepoPath().toAbsolutePath();
+
+ createRepository(snapshotRepoName, "fs", getRepositorySettings(absolutePath1, true));
+
+ Client client = client();
+ Settings indexSettings = getIndexSettings(1, 0).build();
+ createIndex(indexName, indexSettings);
+ ensureGreen(indexName);
+
+ // 3 batches × refresh+flush → multiple segments with distinct writer_generations
+ for (int batch = 0; batch < 3; batch++) {
+ indexDocuments(client, indexName, batch * 5, batch * 5 + 5);
+ refresh(indexName);
+ flush(indexName);
+ }
+
+ // Capture pre-snapshot writer_generation on each Lucene segment
+ IndexShard sourceShard = getShardZero(indexName);
+ Map preSnapshotWriterGens = new HashMap<>();
+ org.apache.lucene.index.SegmentInfos infos = org.apache.lucene.index.SegmentInfos.readLatestCommit(sourceShard.store().directory());
+ for (org.apache.lucene.index.SegmentCommitInfo sci : infos) {
+ String genAttr = sci.info.getAttribute(org.opensearch.be.lucene.index.LuceneWriter.WRITER_GENERATION_ATTRIBUTE);
+ preSnapshotWriterGens.put(sci.info.name, genAttr);
+ }
+ assertFalse("pre-snapshot writer_generation map must not be empty", preSnapshotWriterGens.isEmpty());
+ assertTrue(
+ "at least one segment must have writer_generation attribute pre-snapshot, got: " + preSnapshotWriterGens,
+ preSnapshotWriterGens.values().stream().anyMatch(java.util.Objects::nonNull)
+ );
+
+ // Snapshot, delete, restore
+ SnapshotInfo snapshotInfo = createSnapshot(snapshotRepoName, snapshotName, new ArrayList<>());
+ assertThat(snapshotInfo.state(), equalTo(SnapshotState.SUCCESS));
+
+ assertAcked(client().admin().indices().delete(new DeleteIndexRequest(indexName)).get());
+ RestoreSnapshotResponse restoreResponse = client.admin()
+ .cluster()
+ .prepareRestoreSnapshot(snapshotRepoName, snapshotName)
+ .setWaitForCompletion(true)
+ .setIndices(indexName)
+ .get();
+ assertEquals(RestStatus.OK, restoreResponse.status());
+ ensureGreen(indexName);
+
+ // Verify writer_generation attribute survived restore
+ IndexShard restoredShard = getShardZero(indexName);
+ Map postRestoreWriterGens = new HashMap<>();
+ org.apache.lucene.index.SegmentInfos infosAfter = org.apache.lucene.index.SegmentInfos.readLatestCommit(
+ restoredShard.store().directory()
+ );
+ for (org.apache.lucene.index.SegmentCommitInfo sci : infosAfter) {
+ postRestoreWriterGens.put(
+ sci.info.name,
+ sci.info.getAttribute(org.opensearch.be.lucene.index.LuceneWriter.WRITER_GENERATION_ATTRIBUTE)
+ );
+ }
+
+ // Every pre-snapshot segment with writer_generation must STILL have it after restore.
+ for (Map.Entry e : preSnapshotWriterGens.entrySet()) {
+ String segName = e.getKey();
+ String preGen = e.getValue();
+ if (preGen == null) continue;
+ String postGen = postRestoreWriterGens.get(segName);
+ assertNotNull(
+ "segment "
+ + segName
+ + " had writer_generation="
+ + preGen
+ + " pre-snapshot but is missing post-restore. post-restore segs: "
+ + postRestoreWriterGens,
+ postGen
+ );
+ assertEquals("writer_generation must be preserved across V2 snapshot/restore for segment " + segName, preGen, postGen);
+ }
+ }
+
+ /**
+ * DFA failure recovery test: if a V2 snapshot fails during finalization
+ * (cluster-manager block injected), the source DFA index must be UNCHANGED — same docs,
+ * same catalog generation, same on-disk format dirs. A subsequent V2 snapshot must succeed.
+ *
+ * Mirrors {@code ConcurrentSnapshotsV2IT.testCreateSnapshotFailInFinalize} but adds
+ * DFA-specific assertions on the source state.
+ */
+ public void testV2SnapshotFailInFinalizeDoesNotCorruptDFAState() throws Exception {
+ final String clusterManagerNode = internalCluster().startClusterManagerOnlyNode();
+ internalCluster().startDataAndWarmNodes(1);
+ String indexName = "dfa-fail-finalize";
+ String snapshotRepoName = "test-fail-finalize-repo";
+ Path absolutePath1 = randomRepoPath().toAbsolutePath();
+
+ // Use 'mock' repo type so the test framework can inject failures.
+ Settings.Builder settings = Settings.builder()
+ .put("location", absolutePath1)
+ .put(BlobStoreRepository.REMOTE_STORE_INDEX_SHALLOW_COPY.getKey(), true)
+ .put(BlobStoreRepository.SHALLOW_SNAPSHOT_V2.getKey(), true);
+ createRepository(snapshotRepoName, "mock", settings);
+
+ Client client = client();
+ Settings indexSettings = getIndexSettings(1, 0).build();
+ createIndex(indexName, indexSettings);
+ ensureGreen(indexName);
+
+ final int numDocs = 15;
+ indexDocuments(client, indexName, 0, numDocs);
+ refresh(indexName);
+ flush(indexName);
+
+ // Capture exhaustive pre-failure state for assertion that source is unaffected by failed snapshot
+ IndexShard sourceShardBefore = getShardZero(indexName);
+ PreSnapshotState preState = capturePreSnapshotState(client, indexName, sourceShardBefore);
+
+ // Block finalization, fire snapshot, wait for block, then unblock to fail it
+ blockClusterManagerFromFinalizingSnapshotOnIndexFile(snapshotRepoName);
+ final org.opensearch.common.action.ActionFuture<
+ org.opensearch.action.admin.cluster.snapshots.create.CreateSnapshotResponse> snapshotFuture = startFullSnapshot(
+ snapshotRepoName,
+ "snap-fail"
+ );
+ awaitNumberOfSnapshotsInProgress(1);
+ waitForBlock(clusterManagerNode, snapshotRepoName, TimeValue.timeValueSeconds(30L));
+ unblockNode(snapshotRepoName, clusterManagerNode);
+
+ // The blocked snapshot must throw SnapshotException
+ expectThrows(org.opensearch.snapshots.SnapshotException.class, snapshotFuture::actionGet);
+
+ // ─── DFA-specific: verify source index was NOT corrupted by the failure ───
+ IndexShard sourceShardAfter = getShardZero(indexName);
+ // Doc count unchanged
+ assertDocCountInIndex(client, indexName, preState.docCount);
+ // Catalog file set unchanged (excluding segments_N which can change due to commit ops)
+ Set catalogAfter = DataFormatAwareITUtils.catalogFilesExcludingSegments(sourceShardAfter);
+ assertEquals(
+ "DFA catalog files must be unchanged after failed V2 snapshot finalize: missing="
+ + diff(preState.catalogFilesExcludingSegments, catalogAfter)
+ + " extra="
+ + diff(catalogAfter, preState.catalogFilesExcludingSegments),
+ preState.catalogFilesExcludingSegments,
+ catalogAfter
+ );
+ // Catalog generation must be at least pre-failure (commit may have advanced it slightly during failure handling)
+ long genAfter;
+ try (
+ org.opensearch.common.concurrent.GatedCloseable ref = sourceShardAfter
+ .getCatalogSnapshot()
+ ) {
+ genAfter = ref.get().getGeneration();
+ }
+ assertTrue(
+ "catalog generation must be >= pre-failure generation: pre=" + preState.catalogGeneration + " post=" + genAfter,
+ genAfter >= preState.catalogGeneration
+ );
+ // DFA on-disk layout intact
+ assertAllFormatDirsHaveFiles(sourceShardAfter);
+ assertLuceneIndexDirContents(sourceShardAfter);
+ DataFormatAwareITUtils.assertCatalogMatchesLocalAndRemote(sourceShardAfter);
+
+ // ─── Verify retry: a subsequent V2 snapshot must succeed (repo state cleaned) ───
+ SnapshotInfo retryInfo = createSnapshot(snapshotRepoName, "snap-retry-success", new ArrayList<>());
+ assertThat(retryInfo.state(), equalTo(SnapshotState.SUCCESS));
+ assertEquals("retry snapshot must include exactly the DFA index", List.of(indexName), retryInfo.indices());
+
+ // Repo must contain only the successful retry snapshot
+ List snapshots = client.admin().cluster().prepareGetSnapshots(snapshotRepoName).get().getSnapshots();
+ assertEquals("only the successful retry snapshot must remain in repo", 1, snapshots.size());
+ assertEquals("snap-retry-success", snapshots.get(0).snapshotId().getName());
+ }
+
+ /**
+ * DFA failure handling test: verify that invalid restore requests for a
+ * DFA index are rejected cleanly with {@link org.opensearch.snapshots.SnapshotRestoreException}
+ * — they must NOT partially restore or leak files.
+ *
+ * Mirrors {@code RestoreShallowSnapshotV2IT.testInvalidRestoreRequestScenarios} but exercises
+ * DFA-specific setting overrides too.
+ */
+ public void testV2InvalidRestoreRequestForDFAIndex() throws Exception {
+ internalCluster().startClusterManagerOnlyNode();
+ internalCluster().startDataAndWarmNodes(1);
+ String indexName = "dfa-invalid-restore";
+ String snapshotRepoName = "test-invalid-restore-repo";
+ String snapshotName = "snap-invalid";
+ Path absolutePath1 = randomRepoPath().toAbsolutePath();
+
+ createRepository(snapshotRepoName, "fs", getRepositorySettings(absolutePath1, true));
+
+ Client client = client();
+ Settings indexSettings = getIndexSettings(1, 0).build();
+ createIndex(indexName, indexSettings);
+ ensureGreen(indexName);
+
+ final int numDocs = 10;
+ indexDocuments(client, indexName, 0, numDocs);
+ refresh(indexName);
+ flush(indexName);
+
+ // Take V2 snapshot
+ SnapshotInfo snapshotInfo = createSnapshot(snapshotRepoName, snapshotName, new ArrayList<>());
+ assertThat(snapshotInfo.state(), equalTo(SnapshotState.SUCCESS));
+
+ // Delete the source index so we can attempt restore
+ assertAcked(client().admin().indices().delete(new DeleteIndexRequest(indexName)).get());
+
+ // ─── Invalid request 1: try to disable remote_store on restore ───
+ Settings disableRemoteStore = Settings.builder().put(SETTING_REMOTE_STORE_ENABLED, false).build();
+ org.opensearch.snapshots.SnapshotRestoreException ex1 = expectThrows(
+ org.opensearch.snapshots.SnapshotRestoreException.class,
+ () -> client.admin()
+ .cluster()
+ .prepareRestoreSnapshot(snapshotRepoName, snapshotName)
+ .setWaitForCompletion(true)
+ .setIndices(indexName)
+ .setIndexSettings(disableRemoteStore)
+ .get()
+ );
+ assertTrue(
+ "rejection message must mention remote_store: " + ex1.getMessage(),
+ ex1.getMessage().toLowerCase(java.util.Locale.ROOT).contains("remote_store")
+ );
+
+ // ─── Invalid request 2: try to change segment.repository ───
+ Settings changeSegmentRepo = Settings.builder().put(SETTING_REMOTE_SEGMENT_STORE_REPOSITORY, "different-repo").build();
+ expectThrows(
+ org.opensearch.snapshots.SnapshotRestoreException.class,
+ () -> client.admin()
+ .cluster()
+ .prepareRestoreSnapshot(snapshotRepoName, snapshotName)
+ .setWaitForCompletion(true)
+ .setIndices(indexName)
+ .setIndexSettings(changeSegmentRepo)
+ .get()
+ );
+
+ // ─── Invalid request 3: try to change translog.repository ───
+ Settings changeTranslogRepo = Settings.builder().put(SETTING_REMOTE_TRANSLOG_STORE_REPOSITORY, "different-translog-repo").build();
+ expectThrows(
+ org.opensearch.snapshots.SnapshotRestoreException.class,
+ () -> client.admin()
+ .cluster()
+ .prepareRestoreSnapshot(snapshotRepoName, snapshotName)
+ .setWaitForCompletion(true)
+ .setIndices(indexName)
+ .setIndexSettings(changeTranslogRepo)
+ .get()
+ );
+
+ // ─── After all failures: verify NO partially-restored index exists ───
+ assertFalse("invalid restore must NOT partially create the index", indexExists(indexName));
+
+ // ─── Verify a subsequent VALID restore still works ───
+ RestoreSnapshotResponse restoreResponse = client.admin()
+ .cluster()
+ .prepareRestoreSnapshot(snapshotRepoName, snapshotName)
+ .setWaitForCompletion(true)
+ .setIndices(indexName)
+ .get();
+ assertEquals(RestStatus.OK, restoreResponse.status());
+ ensureGreen(indexName);
+ assertDocCountInIndex(client, indexName, numDocs);
+ }
+
+}
diff --git a/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareRestoreShallowSnapshotV2WithLuceneIT.java b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareRestoreShallowSnapshotV2WithLuceneIT.java
new file mode 100644
index 0000000000000..07647b3355323
--- /dev/null
+++ b/sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/DataFormatAwareRestoreShallowSnapshotV2WithLuceneIT.java
@@ -0,0 +1,47 @@
+/*
+ * 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.composite;
+
+import org.opensearch.common.settings.Settings;
+
+import java.util.List;
+
+/**
+ * Variant of {@link DataFormatAwareRestoreShallowSnapshotV2IT} that uses parquet as the primary
+ * format with lucene as a secondary format (so the Lucene index/ directory
+ * contains real segment data files in addition to {@code segments_N}).
+ *
+ *
Inherits all V2 snapshot tests from the base class. The only difference is the index
+ * settings: {@code index.composite.secondary_data_formats} is {@code ["lucene"]} instead of
+ * the default empty list. This exercises the parquet+lucene-secondary code paths during
+ * V2 snapshot creation, restore, and validation.
+ *
+ *
Mirrors the {@code DataFormatAwareReplicationWithLuceneIT} pattern: same test surface,
+ * different format combination.
+ *
+ * @opensearch.experimental
+ */
+public class DataFormatAwareRestoreShallowSnapshotV2WithLuceneIT extends DataFormatAwareRestoreShallowSnapshotV2IT {
+
+ public DataFormatAwareRestoreShallowSnapshotV2WithLuceneIT(Settings nodeSettings) {
+ super(nodeSettings);
+ }
+
+ /** Lucene is configured as a secondary format → both formats produce searchable segment files. */
+ @Override
+ protected List getSecondaryDataFormats() {
+ return List.of("lucene");
+ }
+
+ /** Drives format-aware on-disk assertions: index/ must contain segment data files beyond segments_N. */
+ @Override
+ protected boolean hasLuceneSecondary() {
+ return true;
+ }
+}
diff --git a/server/src/main/java/org/opensearch/action/admin/cluster/remotestore/metadata/TransportRemoteStoreMetadataAction.java b/server/src/main/java/org/opensearch/action/admin/cluster/remotestore/metadata/TransportRemoteStoreMetadataAction.java
index c72820d8df19a..6bf0e63babc5d 100644
--- a/server/src/main/java/org/opensearch/action/admin/cluster/remotestore/metadata/TransportRemoteStoreMetadataAction.java
+++ b/server/src/main/java/org/opensearch/action/admin/cluster/remotestore/metadata/TransportRemoteStoreMetadataAction.java
@@ -200,7 +200,9 @@ private Map> getSegmentMetadata(
shardId,
indexSettings.getRemoteStorePathStrategy(),
null,
- RemoteStoreUtils.isServerSideEncryptionEnabledIndex(indexSettings.getIndexMetadata())
+ RemoteStoreUtils.isServerSideEncryptionEnabledIndex(indexSettings.getIndexMetadata()),
+ false,
+ indexSettings
);
Map segmentMetadataMapWithFilenames = remoteDirectory.readLatestNMetadataFiles(5);
diff --git a/server/src/main/java/org/opensearch/action/admin/cluster/repositories/cleanup/TransportCleanupRepositoryAction.java b/server/src/main/java/org/opensearch/action/admin/cluster/repositories/cleanup/TransportCleanupRepositoryAction.java
index 54b6d7a914a9b..7e9b593353979 100644
--- a/server/src/main/java/org/opensearch/action/admin/cluster/repositories/cleanup/TransportCleanupRepositoryAction.java
+++ b/server/src/main/java/org/opensearch/action/admin/cluster/repositories/cleanup/TransportCleanupRepositoryAction.java
@@ -52,6 +52,7 @@
import org.opensearch.common.inject.Inject;
import org.opensearch.core.action.ActionListener;
import org.opensearch.core.common.io.stream.StreamInput;
+import org.opensearch.index.engine.dataformat.DataFormatRegistry;
import org.opensearch.index.store.RemoteSegmentStoreDirectoryFactory;
import org.opensearch.index.store.lockmanager.RemoteStoreLockManagerFactory;
import org.opensearch.indices.RemoteStoreSettings;
@@ -114,7 +115,8 @@ public TransportCleanupRepositoryAction(
ThreadPool threadPool,
ActionFilters actionFilters,
IndexNameExpressionResolver indexNameExpressionResolver,
- RemoteStoreSettings remoteStoreSettings
+ RemoteStoreSettings remoteStoreSettings,
+ @Nullable DataFormatRegistry dataFormatRegistry
) {
super(
CleanupRepositoryAction.NAME,
@@ -130,7 +132,8 @@ public TransportCleanupRepositoryAction(
this.remoteSegmentStoreDirectoryFactory = new RemoteSegmentStoreDirectoryFactory(
() -> repositoriesService,
threadPool,
- remoteStoreSettings.getSegmentsPathFixedPrefix()
+ remoteStoreSettings.getSegmentsPathFixedPrefix(),
+ dataFormatRegistry
);
this.remoteStoreLockManagerFactory = new RemoteStoreLockManagerFactory(
() -> repositoriesService,
diff --git a/server/src/main/java/org/opensearch/index/shard/StoreRecovery.java b/server/src/main/java/org/opensearch/index/shard/StoreRecovery.java
index d032171eea6bc..27ea6e90a8f04 100644
--- a/server/src/main/java/org/opensearch/index/shard/StoreRecovery.java
+++ b/server/src/main/java/org/opensearch/index/shard/StoreRecovery.java
@@ -418,13 +418,16 @@ void recoverFromSnapshotAndRemoteStore(
threadPool,
segmentsPathFixedPrefix
);
+ // Pass IndexSettings so DFA indices route to DataFormatAwareRemoteDirectory; Lucene-only falls through unchanged.
RemoteSegmentStoreDirectory sourceRemoteDirectory = (RemoteSegmentStoreDirectory) directoryFactory.newDirectory(
remoteStoreRepository,
indexUUID,
shardId,
shallowCopyShardMetadata.getRemoteStorePathStrategy(),
null,
- RemoteStoreUtils.isServerSideEncryptionEnabledIndex(indexShard.indexSettings.getIndexMetadata())
+ RemoteStoreUtils.isServerSideEncryptionEnabledIndex(indexShard.indexSettings.getIndexMetadata()),
+ false,
+ indexShard.indexSettings
);
RemoteSegmentMetadata remoteSegmentMetadata = sourceRemoteDirectory.initializeToSpecificCommit(
primaryTerm,
@@ -507,7 +510,9 @@ void recoverShallowSnapshotV2(
shardId,
remoteStorePathStrategy,
null,
- RemoteStoreUtils.isServerSideEncryptionEnabledIndex(prevIndexMetadata)
+ RemoteStoreUtils.isServerSideEncryptionEnabledIndex(prevIndexMetadata),
+ false,
+ indexShard.indexSettings
);
RemoteSegmentMetadata remoteSegmentMetadata = sourceRemoteDirectory.initializeToSpecificTimestamp(
recoverySource.pinnedTimestamp()
diff --git a/server/src/main/java/org/opensearch/index/store/RemoteSegmentStoreDirectory.java b/server/src/main/java/org/opensearch/index/store/RemoteSegmentStoreDirectory.java
index 91e11e78efc34..1b25a031cf66a 100644
--- a/server/src/main/java/org/opensearch/index/store/RemoteSegmentStoreDirectory.java
+++ b/server/src/main/java/org/opensearch/index/store/RemoteSegmentStoreDirectory.java
@@ -25,6 +25,7 @@
import org.apache.lucene.store.IndexOutput;
import org.apache.lucene.util.Version;
import org.opensearch.cluster.metadata.CryptoMetadata;
+import org.opensearch.cluster.metadata.IndexMetadata;
import org.opensearch.common.CheckedFunction;
import org.opensearch.common.Nullable;
import org.opensearch.common.UUIDs;
@@ -35,8 +36,10 @@
import org.opensearch.common.io.VersionedCodecStreamWrapper;
import org.opensearch.common.logging.Loggers;
import org.opensearch.common.lucene.store.ByteArrayIndexInput;
+import org.opensearch.common.settings.Settings;
import org.opensearch.core.action.ActionListener;
import org.opensearch.core.index.shard.ShardId;
+import org.opensearch.index.IndexSettings;
import org.opensearch.index.engine.exec.coord.CatalogSnapshot;
import org.opensearch.index.engine.exec.coord.LuceneVersionConverter;
import org.opensearch.index.remote.RemoteStorePathStrategy;
@@ -1327,6 +1330,18 @@ public void deleteStaleSegmentsAsync(int lastNMetadataFilesToKeep, ActionListene
}
}
+ /**
+ * Backward-compatible 6-arg overload preserved for the 2.3.0 {@code @PublicApi} contract.
+ * Delegates to the 7-arg variant with a {@code null} {@link IndexMetadata} — equivalent to
+ * the prior behaviour for callers that don't need data-format-aware routing.
+ *
+ * @deprecated Use the 7-arg variant that accepts {@link IndexMetadata} so that DFA-enabled
+ * indices route to {@link org.opensearch.index.store.remote.DataFormatAwareRemoteDirectory}
+ * during cleanup. This overload remains for backward compatibility but does not
+ * enumerate per-format files (e.g., {@code parquet/}) and may leak them on cleanup
+ * of DFA indices.
+ */
+ @Deprecated
public static void remoteDirectoryCleanup(
RemoteSegmentStoreDirectoryFactory remoteDirectoryFactory,
String remoteStoreRepoForIndex,
@@ -1334,13 +1349,30 @@ public static void remoteDirectoryCleanup(
ShardId shardId,
RemoteStorePathStrategy pathStrategy,
boolean forceClean
+ ) {
+ remoteDirectoryCleanup(remoteDirectoryFactory, remoteStoreRepoForIndex, indexUUID, shardId, pathStrategy, forceClean, null);
+ }
+
+ public static void remoteDirectoryCleanup(
+ RemoteSegmentStoreDirectoryFactory remoteDirectoryFactory,
+ String remoteStoreRepoForIndex,
+ String indexUUID,
+ ShardId shardId,
+ RemoteStorePathStrategy pathStrategy,
+ boolean forceClean,
+ IndexMetadata indexMetadata
) {
try {
+ IndexSettings indexSettings = indexMetadata != null ? new IndexSettings(indexMetadata, Settings.EMPTY) : null;
RemoteSegmentStoreDirectory remoteSegmentStoreDirectory = (RemoteSegmentStoreDirectory) remoteDirectoryFactory.newDirectory(
remoteStoreRepoForIndex,
indexUUID,
shardId,
- pathStrategy
+ pathStrategy,
+ null, // indexFixedPrefix
+ false, // isServerSideEncryptionEnabled
+ false, // isWarmIndex
+ indexSettings
);
if (forceClean) {
remoteSegmentStoreDirectory.delete();
diff --git a/server/src/main/java/org/opensearch/index/store/RemoteSegmentStoreDirectoryFactory.java b/server/src/main/java/org/opensearch/index/store/RemoteSegmentStoreDirectoryFactory.java
index 5a2bb215f809c..e139bf071814e 100644
--- a/server/src/main/java/org/opensearch/index/store/RemoteSegmentStoreDirectoryFactory.java
+++ b/server/src/main/java/org/opensearch/index/store/RemoteSegmentStoreDirectoryFactory.java
@@ -11,6 +11,7 @@
import org.apache.logging.log4j.LogManager;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.LockFactory;
+import org.opensearch.common.Nullable;
import org.opensearch.common.annotation.PublicApi;
import org.opensearch.common.blobstore.BlobPath;
import org.opensearch.core.index.shard.ShardId;
@@ -65,7 +66,7 @@ public RemoteSegmentStoreDirectoryFactory(
Supplier repositoriesService,
ThreadPool threadPool,
String segmentsPathFixedPrefix,
- DataFormatRegistry dataFormatRegistry
+ @Nullable DataFormatRegistry dataFormatRegistry
) {
this.repositoriesService = repositoriesService;
this.segmentsPathFixedPrefix = segmentsPathFixedPrefix;
diff --git a/server/src/main/java/org/opensearch/node/Node.java b/server/src/main/java/org/opensearch/node/Node.java
index 5844a83128636..43adb22080546 100644
--- a/server/src/main/java/org/opensearch/node/Node.java
+++ b/server/src/main/java/org/opensearch/node/Node.java
@@ -1491,7 +1491,8 @@ protected Node(final Environment initialEnvironment, Collection clas
transportService,
actionModule.getActionFilters(),
remoteStorePinnedTimestampService,
- remoteStoreSettings
+ remoteStoreSettings,
+ dataFormatRegistry
);
SnapshotShardsService snapshotShardsService = new SnapshotShardsService(
settings,
@@ -1741,6 +1742,7 @@ protected Node(final Environment initialEnvironment, Collection clas
b.bind(GatewayMetaState.class).toInstance(gatewayMetaState);
b.bind(Discovery.class).toInstance(discovery);
b.bind(RemoteStoreSettings.class).toInstance(remoteStoreSettings);
+ b.bind(DataFormatRegistry.class).toInstance(dataFormatRegistry);
{
b.bind(PeerRecoverySourceService.class)
.toInstance(new PeerRecoverySourceService(transportService, indicesService, recoverySettings));
diff --git a/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java b/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java
index 91b44487d6ddc..b2dee574796a8 100644
--- a/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java
+++ b/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java
@@ -1640,7 +1640,8 @@ public static void remoteDirectoryCleanupAsync(
ShardId shardId,
String threadPoolName,
RemoteStorePathStrategy pathStrategy,
- boolean forceClean
+ boolean forceClean,
+ IndexMetadata indexMetadata
) {
threadpool.executor(threadPoolName)
.execute(
@@ -1651,7 +1652,8 @@ public static void remoteDirectoryCleanupAsync(
indexUUID,
shardId,
pathStrategy,
- forceClean
+ forceClean,
+ indexMetadata
),
indexUUID,
shardId
@@ -1708,7 +1710,9 @@ protected void releaseRemoteStoreLockAndCleanup(
new ShardId(Index.UNKNOWN_INDEX_NAME, indexUUID, Integer.parseInt(shardId)),
ThreadPool.Names.REMOTE_PURGE,
remoteStoreShardShallowCopySnapshot.getRemoteStorePathStrategy(),
- false
+ false,
+ null // V1 shallow-copy is a Lucene-only mode — DFA indices use V2 snapshots only. If a DFA index were ever associated with
+ // a V1 snapshot, per-format files (e.g., parquet/) would leak on cleanup; tracked as a known limitation.
);
}
}
@@ -2389,7 +2393,8 @@ private void cleanRemoteStoreDirectoryIfNeeded(
shard,
ThreadPool.Names.REMOTE_PURGE,
remoteStorePathStrategy,
- forceClean
+ forceClean,
+ prevIndexMetadata // ← carries DFA flag through factory → DataFormatAwareRemoteDirectory
);
remoteTranslogCleanupAsync(
remoteTranslogRepository,
diff --git a/server/src/main/java/org/opensearch/snapshots/SnapshotsService.java b/server/src/main/java/org/opensearch/snapshots/SnapshotsService.java
index 09bc76f61d427..d9b6d3a303a4d 100644
--- a/server/src/main/java/org/opensearch/snapshots/SnapshotsService.java
+++ b/server/src/main/java/org/opensearch/snapshots/SnapshotsService.java
@@ -235,7 +235,8 @@ public SnapshotsService(
TransportService transportService,
ActionFilters actionFilters,
@Nullable RemoteStorePinnedTimestampService remoteStorePinnedTimestampService,
- RemoteStoreSettings remoteStoreSettings
+ RemoteStoreSettings remoteStoreSettings,
+ @Nullable org.opensearch.index.engine.dataformat.DataFormatRegistry dataFormatRegistry
) {
this.clusterService = clusterService;
this.indexNameExpressionResolver = indexNameExpressionResolver;
@@ -245,10 +246,12 @@ public SnapshotsService(
remoteStoreSettings.getSegmentsPathFixedPrefix()
);
this.threadPool = transportService.getThreadPool();
+ // dataFormatRegistry pre-registers DFA formats so cleanup deletes per-format files (e.g., parquet/).
this.remoteSegmentStoreDirectoryFactory = new RemoteSegmentStoreDirectoryFactory(
() -> repositoriesService,
threadPool,
- remoteStoreSettings.getSegmentsPathFixedPrefix()
+ remoteStoreSettings.getSegmentsPathFixedPrefix(),
+ dataFormatRegistry
);
this.transportService = transportService;
this.remoteStorePinnedTimestampService = remoteStorePinnedTimestampService;
diff --git a/server/src/test/java/org/opensearch/index/shard/IndexShardTests.java b/server/src/test/java/org/opensearch/index/shard/IndexShardTests.java
index 4ebd49126e3af..6b2d88d448226 100644
--- a/server/src/test/java/org/opensearch/index/shard/IndexShardTests.java
+++ b/server/src/test/java/org/opensearch/index/shard/IndexShardTests.java
@@ -5561,4 +5561,37 @@ public void testCacheWrapperReader() throws IOException {
closeShards(primary);
assertTrue(primary.nonClosingReaderWrapperCache().isEmpty());
}
+
+ /**
+ * Verifies that {@code isRemoteSegmentStoreInSync} uses {@code getCatalogSnapshot()} (the unified
+ * catalog API) rather than the legacy {@code getSegmentInfosSnapshot()}. After indexing and refreshing,
+ * the catalog snapshot files should match the remote uploaded files, making the method return true.
+ * Guards against regressions where the method reverts to using getSegmentInfosSnapshot().
+ */
+ public void testIsRemoteSegmentStoreInSyncUsesCatalogSnapshot() throws Exception {
+ String remoteStorePath = createTempDir().toString();
+ IndexShard shard = newStartedShard(
+ true,
+ Settings.builder()
+ .put(IndexMetadata.SETTING_REPLICATION_TYPE, ReplicationType.SEGMENT)
+ .put(IndexMetadata.SETTING_REMOTE_STORE_ENABLED, true)
+ .put(IndexMetadata.SETTING_REMOTE_SEGMENT_STORE_REPOSITORY, remoteStorePath + "__test")
+ .put(IndexMetadata.SETTING_REMOTE_TRANSLOG_STORE_REPOSITORY, remoteStorePath + "__test")
+ .build(),
+ new EngineBackedIndexerFactory(new InternalEngineFactory())
+ );
+ indexDoc(shard, "_doc", "1");
+ shard.refresh("test");
+
+ // After refresh, remote sync should have completed and catalog snapshot files should match remote
+ assertTrue("isRemoteSegmentStoreInSync should return true after refresh", shard.isRemoteSegmentStoreInSync());
+
+ // Verify getCatalogSnapshot returns non-empty files (proving it's being used)
+ try (GatedCloseable snap = shard.getCatalogSnapshot()) {
+ Collection catalogFiles = snap.get().getFiles(true);
+ assertFalse("Catalog snapshot should have files after indexing", catalogFiles.isEmpty());
+ }
+
+ closeShards(shard);
+ }
}
diff --git a/server/src/test/java/org/opensearch/index/store/RemoteSegmentStoreDirectoryFactoryTests.java b/server/src/test/java/org/opensearch/index/store/RemoteSegmentStoreDirectoryFactoryTests.java
index b8ae85a5af2b3..679915679229e 100644
--- a/server/src/test/java/org/opensearch/index/store/RemoteSegmentStoreDirectoryFactoryTests.java
+++ b/server/src/test/java/org/opensearch/index/store/RemoteSegmentStoreDirectoryFactoryTests.java
@@ -9,6 +9,7 @@
package org.opensearch.index.store;
import org.apache.lucene.store.Directory;
+import org.apache.lucene.store.FilterDirectory;
import org.opensearch.action.LatchedActionListener;
import org.opensearch.cluster.metadata.IndexMetadata;
import org.opensearch.common.blobstore.BlobContainer;
@@ -16,10 +17,14 @@
import org.opensearch.common.blobstore.BlobPath;
import org.opensearch.common.blobstore.BlobStore;
import org.opensearch.common.settings.Settings;
+import org.opensearch.common.util.FeatureFlags;
import org.opensearch.core.action.ActionListener;
import org.opensearch.core.index.shard.ShardId;
import org.opensearch.index.IndexSettings;
+import org.opensearch.index.engine.dataformat.DataFormatRegistry;
+import org.opensearch.index.remote.RemoteStorePathStrategy;
import org.opensearch.index.shard.ShardPath;
+import org.opensearch.index.store.remote.DataFormatAwareRemoteDirectory;
import org.opensearch.repositories.RepositoriesService;
import org.opensearch.repositories.RepositoryMissingException;
import org.opensearch.repositories.blobstore.BlobStoreRepository;
@@ -121,4 +126,105 @@ public void testNewDirectoryRepositoryDoesNotExist() {
assertThrows(RepositoryMissingException.class, () -> remoteSegmentStoreDirectoryFactory.newDirectory(indexSettings, shardPath));
}
+ /**
+ * Verifies that when {@code IndexSettings.isPluggableDataFormatEnabled()} is true, the 8-arg
+ * {@code newDirectory} creates a {@link DataFormatAwareRemoteDirectory} as the data directory.
+ * Guards against regressions where the DFA routing branch is accidentally removed or bypassed.
+ */
+ @LockFeatureFlag(FeatureFlags.PLUGGABLE_DATAFORMAT_EXPERIMENTAL_FLAG)
+ public void testNewDirectoryWithPluggableDataFormatEnabled() throws IOException {
+ RemoteSegmentStoreDirectoryFactory factory = new RemoteSegmentStoreDirectoryFactory(
+ repositoriesServiceSupplier,
+ threadPool,
+ "",
+ mock(DataFormatRegistry.class)
+ );
+ Settings settings = Settings.builder()
+ .put(IndexMetadata.SETTING_INDEX_UUID, "uuid_1")
+ .put(IndexMetadata.SETTING_REMOTE_SEGMENT_STORE_REPOSITORY, "remote_store_repository")
+ .put("index.pluggable.dataformat.enabled", true)
+ .build();
+ IndexSettings indexSettings = IndexSettingsModule.newIndexSettings("foo", settings);
+
+ BlobStoreRepository repository = mock(BlobStoreRepository.class);
+ BlobStore blobStore = mock(BlobStore.class);
+ BlobContainer blobContainer = mock(BlobContainer.class);
+ when(repository.blobStore(false)).thenReturn(blobStore);
+ when(repository.blobStore()).thenReturn(blobStore);
+ when(repository.basePath()).thenReturn(new BlobPath());
+ when(blobStore.blobContainer(any())).thenReturn(blobContainer);
+ when(repositoriesService.repository("remote_store_repository")).thenReturn(repository);
+ doAnswer(invocation -> {
+ LatchedActionListener> listener = invocation.getArgument(3);
+ listener.onResponse(List.of());
+ return null;
+ }).when(blobContainer).listBlobsByPrefixInSortedOrder(any(), eq(METADATA_FILES_TO_FETCH), any(), any(ActionListener.class));
+
+ ShardId shardId = new ShardId(indexSettings.getIndex(), 0);
+ RemoteStorePathStrategy pathStrategy = new RemoteStorePathStrategy(org.opensearch.index.remote.RemoteStoreEnums.PathType.FIXED);
+ try (
+ Directory directory = factory.newDirectory(
+ "remote_store_repository",
+ "uuid_1",
+ shardId,
+ pathStrategy,
+ null,
+ false,
+ false,
+ indexSettings
+ )
+ ) {
+ assertTrue(directory instanceof RemoteSegmentStoreDirectory);
+ Directory delegate = ((FilterDirectory) directory).getDelegate();
+ assertTrue(
+ "Expected DataFormatAwareRemoteDirectory but got " + delegate.getClass(),
+ delegate instanceof DataFormatAwareRemoteDirectory
+ );
+ }
+ }
+
+ /**
+ * Verifies that when {@code IndexSettings.isPluggableDataFormatEnabled()} is false (or indexSettings is null),
+ * the 8-arg {@code newDirectory} creates a plain {@link RemoteDirectory} (not DataFormatAwareRemoteDirectory).
+ * Guards against regressions where the DFA branch is incorrectly triggered.
+ */
+ public void testNewDirectoryWithPluggableDataFormatDisabled() throws IOException {
+ BlobStoreRepository repository = mock(BlobStoreRepository.class);
+ BlobStore blobStore = mock(BlobStore.class);
+ BlobContainer blobContainer = mock(BlobContainer.class);
+ when(repository.blobStore(false)).thenReturn(blobStore);
+ when(repository.blobStore()).thenReturn(blobStore);
+ when(repository.basePath()).thenReturn(new BlobPath());
+ when(blobStore.blobContainer(any())).thenReturn(blobContainer);
+ when(repositoriesService.repository("remote_store_repository")).thenReturn(repository);
+ doAnswer(invocation -> {
+ LatchedActionListener> listener = invocation.getArgument(3);
+ listener.onResponse(List.of());
+ return null;
+ }).when(blobContainer).listBlobsByPrefixInSortedOrder(any(), eq(METADATA_FILES_TO_FETCH), any(), any(ActionListener.class));
+
+ ShardId shardId = new ShardId("foo", "uuid_1", 0);
+ RemoteStorePathStrategy pathStrategy = new RemoteStorePathStrategy(org.opensearch.index.remote.RemoteStoreEnums.PathType.FIXED);
+ // null indexSettings → plain RemoteDirectory
+ try (
+ Directory directory = remoteSegmentStoreDirectoryFactory.newDirectory(
+ "remote_store_repository",
+ "uuid_1",
+ shardId,
+ pathStrategy,
+ null,
+ false,
+ false,
+ null
+ )
+ ) {
+ assertTrue(directory instanceof RemoteSegmentStoreDirectory);
+ Directory delegate = ((FilterDirectory) directory).getDelegate();
+ assertFalse(
+ "Expected plain RemoteDirectory but got " + delegate.getClass(),
+ delegate instanceof DataFormatAwareRemoteDirectory
+ );
+ }
+ }
+
}
diff --git a/server/src/test/java/org/opensearch/index/store/RemoteSegmentStoreDirectoryTests.java b/server/src/test/java/org/opensearch/index/store/RemoteSegmentStoreDirectoryTests.java
index 8a8b5480854f9..5307438d051f3 100644
--- a/server/src/test/java/org/opensearch/index/store/RemoteSegmentStoreDirectoryTests.java
+++ b/server/src/test/java/org/opensearch/index/store/RemoteSegmentStoreDirectoryTests.java
@@ -22,6 +22,7 @@
import org.apache.lucene.store.OutputStreamIndexOutput;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.Version;
+import org.opensearch.cluster.metadata.IndexMetadata;
import org.opensearch.common.UUIDs;
import org.opensearch.common.blobstore.AsyncMultiStreamBlobContainer;
import org.opensearch.common.blobstore.stream.write.WriteContext;
@@ -30,10 +31,12 @@
import org.opensearch.common.lucene.store.ByteArrayIndexInput;
import org.opensearch.common.settings.ClusterSettings;
import org.opensearch.common.settings.Settings;
+import org.opensearch.common.util.FeatureFlags;
import org.opensearch.core.action.ActionListener;
import org.opensearch.core.common.bytes.BytesReference;
import org.opensearch.core.index.Index;
import org.opensearch.core.index.shard.ShardId;
+import org.opensearch.index.IndexSettings;
import org.opensearch.index.engine.exec.coord.SegmentInfosCatalogSnapshot;
import org.opensearch.index.remote.RemoteStoreEnums.PathHashAlgorithm;
import org.opensearch.index.remote.RemoteStoreEnums.PathType;
@@ -61,6 +64,7 @@
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
+import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import static org.opensearch.index.store.RemoteSegmentStoreDirectory.METADATA_FILES_TO_FETCH;
@@ -72,6 +76,7 @@
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.nullable;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.eq;
@@ -606,7 +611,9 @@ public void testCleanupAsync() throws Exception {
indexShard.shardId(),
new HashMap<>()
);
- when(remoteSegmentStoreDirectoryFactory.newDirectory(any(), any(), any(), any())).thenReturn(remoteSegmentDirectory);
+ when(
+ remoteSegmentStoreDirectoryFactory.newDirectory(any(), any(), any(), any(), nullable(String.class), eq(false), eq(false), any())
+ ).thenReturn(remoteSegmentDirectory);
String repositoryName = "test-repository";
String indexUUID = "test-idx-uuid";
ShardId shardId = new ShardId(Index.UNKNOWN_INDEX_NAME, indexUUID, Integer.parseInt("0"));
@@ -621,9 +628,19 @@ public void testCleanupAsync() throws Exception {
indexUUID,
shardId,
pathStrategy,
- false
+ false,
+ null
+ );
+ verify(remoteSegmentStoreDirectoryFactory).newDirectory(
+ repositoryName,
+ indexUUID,
+ shardId,
+ pathStrategy,
+ null,
+ false,
+ false,
+ (org.opensearch.index.IndexSettings) null
);
- verify(remoteSegmentStoreDirectoryFactory).newDirectory(repositoryName, indexUUID, shardId, pathStrategy);
verify(threadPool, times(0)).executor(ThreadPool.Names.REMOTE_PURGE);
verify(remoteMetadataDirectory).delete();
verify(remoteDataDirectory).delete();
@@ -1671,4 +1688,55 @@ public long getChecksum() throws IOException {
return this.indexOutput.getChecksum();
}
}
+
+ /**
+ * Verifies that {@code remoteDirectoryCleanup} with a non-null {@code IndexMetadata} having the
+ * pluggable data format setting enabled constructs an {@link IndexSettings} with
+ * {@code isPluggableDataFormatEnabled() == true} and passes it to the 8-arg {@code newDirectory}.
+ * Guards against regressions where the IndexMetadata is not propagated through the cleanup path.
+ */
+ @LockFeatureFlag(FeatureFlags.PLUGGABLE_DATAFORMAT_EXPERIMENTAL_FLAG)
+ public void testCleanupAsyncWithDataFormatAwareIndexMetadata() throws Exception {
+ populateMetadata();
+ RemoteSegmentStoreDirectoryFactory factory = mock(RemoteSegmentStoreDirectoryFactory.class);
+ RemoteSegmentStoreDirectory remoteDir = new RemoteSegmentStoreDirectory(
+ remoteDataDirectory,
+ remoteMetadataDirectory,
+ mdLockManager,
+ threadPool,
+ indexShard.shardId(),
+ new HashMap<>()
+ );
+ when(factory.newDirectory(any(), any(), any(), any(), nullable(String.class), eq(false), eq(false), any())).thenReturn(remoteDir);
+
+ String indexUUID = "test-idx-uuid";
+ ShardId shardId = new ShardId(Index.UNKNOWN_INDEX_NAME, indexUUID, 0);
+ RemoteStorePathStrategy pathStrategy = new RemoteStorePathStrategy(PathType.FIXED);
+
+ IndexMetadata indexMetadata = IndexMetadata.builder("test-index")
+ .settings(
+ Settings.builder()
+ .put(IndexMetadata.SETTING_VERSION_CREATED, org.opensearch.Version.CURRENT)
+ .put(IndexMetadata.SETTING_INDEX_UUID, indexUUID)
+ .put(IndexMetadata.SETTING_NUMBER_OF_SHARDS, 1)
+ .put(IndexMetadata.SETTING_NUMBER_OF_REPLICAS, 0)
+ .put("index.pluggable.dataformat.enabled", true)
+ )
+ .build();
+
+ RemoteSegmentStoreDirectory.remoteDirectoryCleanup(factory, "test-repo", indexUUID, shardId, pathStrategy, false, indexMetadata);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(IndexSettings.class);
+ verify(factory).newDirectory(
+ eq("test-repo"),
+ eq(indexUUID),
+ eq(shardId),
+ eq(pathStrategy),
+ eq(null),
+ eq(false),
+ eq(false),
+ captor.capture()
+ );
+ assertTrue("IndexSettings should have pluggable data format enabled", captor.getValue().isPluggableDataFormatEnabled());
+ }
}
diff --git a/server/src/test/java/org/opensearch/repositories/blobstore/BlobStoreRepositoryTests.java b/server/src/test/java/org/opensearch/repositories/blobstore/BlobStoreRepositoryTests.java
index 135875c768ed2..3fb34c51ce663 100644
--- a/server/src/test/java/org/opensearch/repositories/blobstore/BlobStoreRepositoryTests.java
+++ b/server/src/test/java/org/opensearch/repositories/blobstore/BlobStoreRepositoryTests.java
@@ -36,6 +36,7 @@
import org.opensearch.action.admin.cluster.snapshots.create.CreateSnapshotResponse;
import org.opensearch.action.support.GroupedActionListener;
import org.opensearch.action.support.PlainActionFuture;
+import org.opensearch.cluster.metadata.IndexMetadata;
import org.opensearch.cluster.metadata.RepositoryMetadata;
import org.opensearch.cluster.service.ClusterService;
import org.opensearch.common.Priority;
@@ -46,6 +47,7 @@
import org.opensearch.common.blobstore.DeleteResult;
import org.opensearch.common.settings.Setting;
import org.opensearch.common.settings.Settings;
+import org.opensearch.common.util.concurrent.OpenSearchExecutors;
import org.opensearch.core.action.ActionListener;
import org.opensearch.core.common.unit.ByteSizeUnit;
import org.opensearch.core.compress.Compressor;
@@ -53,7 +55,9 @@
import org.opensearch.core.index.shard.ShardId;
import org.opensearch.core.xcontent.NamedXContentRegistry;
import org.opensearch.env.Environment;
+import org.opensearch.index.IndexSettings;
import org.opensearch.index.remote.RemoteStoreEnums;
+import org.opensearch.index.remote.RemoteStorePathStrategy;
import org.opensearch.index.store.RemoteSegmentStoreDirectoryFactory;
import org.opensearch.index.store.lockmanager.RemoteStoreLockManager;
import org.opensearch.index.store.lockmanager.RemoteStoreLockManagerFactory;
@@ -73,8 +77,10 @@
import org.opensearch.snapshots.SnapshotShardPaths.ShardInfo;
import org.opensearch.snapshots.SnapshotState;
import org.opensearch.test.OpenSearchIntegTestCase;
+import org.opensearch.threadpool.ThreadPool;
import org.opensearch.transport.client.Client;
+import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
@@ -86,12 +92,14 @@
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import java.util.stream.Collectors;
+import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import static org.opensearch.repositories.RepositoryDataTests.generateRandomRepoData;
@@ -101,6 +109,8 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.nullable;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -827,4 +837,88 @@ public void testThresholdCappedAtIntMax() {
// then
assertEquals(maxSafeArraySize, expectedThreshold);
}
+
+ /**
+ * Verifies that {@code BlobStoreRepository.remoteDirectoryCleanupAsync} propagates the
+ * {@link IndexMetadata} argument through to {@code RemoteSegmentStoreDirectory.remoteDirectoryCleanup},
+ * which in turn passes it as an {@link IndexSettings} to the factory's 8-arg {@code newDirectory}.
+ * Guards against regressions where IndexMetadata is dropped or replaced with null during cleanup.
+ */
+ public void testRemoteDirectoryCleanupAsyncPropagatesIndexMetadata() throws Exception {
+ RemoteSegmentStoreDirectoryFactory factory = mock(RemoteSegmentStoreDirectoryFactory.class);
+ // factory.newDirectory throws IOException to short-circuit; we only care about the args passed
+ when(factory.newDirectory(anyString(), anyString(), any(), any(), nullable(String.class), eq(false), eq(false), any())).thenThrow(
+ new IOException("expected-short-circuit")
+ );
+
+ ThreadPool tp = mock(ThreadPool.class);
+ ExecutorService directExecutor = OpenSearchExecutors.newDirectExecutorService();
+ when(tp.executor(ThreadPool.Names.REMOTE_PURGE)).thenReturn(directExecutor);
+
+ String indexUUID = "test-uuid";
+ ShardId shardId = new ShardId(new Index("idx", indexUUID), 0);
+ RemoteStorePathStrategy pathStrategy = new RemoteStorePathStrategy(RemoteStoreEnums.PathType.FIXED);
+
+ IndexMetadata indexMetadata = IndexMetadata.builder("idx")
+ .settings(
+ Settings.builder()
+ .put(IndexMetadata.SETTING_VERSION_CREATED, Version.CURRENT)
+ .put(IndexMetadata.SETTING_INDEX_UUID, indexUUID)
+ .put(IndexMetadata.SETTING_NUMBER_OF_SHARDS, 1)
+ .put(IndexMetadata.SETTING_NUMBER_OF_REPLICAS, 0)
+ )
+ .build();
+
+ // Case (a): non-null IndexMetadata propagated
+ BlobStoreRepository.remoteDirectoryCleanupAsync(
+ factory,
+ tp,
+ "repo",
+ indexUUID,
+ shardId,
+ ThreadPool.Names.REMOTE_PURGE,
+ pathStrategy,
+ false,
+ indexMetadata
+ );
+ ArgumentCaptor captor = ArgumentCaptor.forClass(IndexSettings.class);
+ verify(factory).newDirectory(
+ eq("repo"),
+ eq(indexUUID),
+ eq(shardId),
+ eq(pathStrategy),
+ eq(null),
+ eq(false),
+ eq(false),
+ captor.capture()
+ );
+ assertNotNull("IndexSettings should not be null when IndexMetadata is provided", captor.getValue());
+
+ // Case (b): null IndexMetadata propagated as null
+ Mockito.reset(factory);
+ when(factory.newDirectory(anyString(), anyString(), any(), any(), nullable(String.class), eq(false), eq(false), any())).thenThrow(
+ new IOException("expected-short-circuit")
+ );
+ BlobStoreRepository.remoteDirectoryCleanupAsync(
+ factory,
+ tp,
+ "repo",
+ indexUUID,
+ shardId,
+ ThreadPool.Names.REMOTE_PURGE,
+ pathStrategy,
+ false,
+ null
+ );
+ verify(factory).newDirectory(
+ eq("repo"),
+ eq(indexUUID),
+ eq(shardId),
+ eq(pathStrategy),
+ eq(null),
+ eq(false),
+ eq(false),
+ (IndexSettings) eq(null)
+ );
+ }
}
diff --git a/server/src/test/java/org/opensearch/snapshots/SnapshotResiliencyTests.java b/server/src/test/java/org/opensearch/snapshots/SnapshotResiliencyTests.java
index ae6c5ba87a8ce..505b6851aa045 100644
--- a/server/src/test/java/org/opensearch/snapshots/SnapshotResiliencyTests.java
+++ b/server/src/test/java/org/opensearch/snapshots/SnapshotResiliencyTests.java
@@ -2060,7 +2060,8 @@ public void onFailure(final Exception e) {
transportService,
actionFilters,
null,
- DefaultRemoteStoreSettings.INSTANCE
+ DefaultRemoteStoreSettings.INSTANCE,
+ null
);
nodeEnv = new NodeEnvironment(settings, environment);
final NamedXContentRegistry namedXContentRegistry = new NamedXContentRegistry(Collections.emptyList());
@@ -2454,7 +2455,8 @@ public void onFailure(final Exception e) {
threadPool,
actionFilters,
indexNameExpressionResolver,
- DefaultRemoteStoreSettings.INSTANCE
+ DefaultRemoteStoreSettings.INSTANCE,
+ null
)
);
actions.put(
diff --git a/server/src/test/java/org/opensearch/snapshots/SnapshotsServiceTests.java b/server/src/test/java/org/opensearch/snapshots/SnapshotsServiceTests.java
index 2aa061366465e..add8c552e040a 100644
--- a/server/src/test/java/org/opensearch/snapshots/SnapshotsServiceTests.java
+++ b/server/src/test/java/org/opensearch/snapshots/SnapshotsServiceTests.java
@@ -863,7 +863,8 @@ private SnapshotsService createSnapshotsService(ClusterService mockClusterServic
mockTransportService,
mock(ActionFilters.class),
null,
- mock(RemoteStoreSettings.class)
+ mock(RemoteStoreSettings.class),
+ null
);
}