diff --git a/dotcms-integration/src/test/java/com/dotcms/OpenSearchUpgradeSuite.java b/dotcms-integration/src/test/java/com/dotcms/OpenSearchUpgradeSuite.java
index 28a7d54051ae..1f755591ca60 100644
--- a/dotcms-integration/src/test/java/com/dotcms/OpenSearchUpgradeSuite.java
+++ b/dotcms-integration/src/test/java/com/dotcms/OpenSearchUpgradeSuite.java
@@ -1,5 +1,6 @@
package com.dotcms;
+import com.dotcms.content.elasticsearch.business.ContentletDataGenPhaseControlIT;
import com.dotcms.content.elasticsearch.business.ContentletIndexAPIImplPhaseSwitchIntegrationTest;
import com.dotcms.content.elasticsearch.business.ContentletIndexAPIImplMigrationIntegrationTest;
import com.dotcms.content.index.opensearch.ContentFactoryIndexOperationsOSIntegrationTest;
@@ -44,6 +45,7 @@
OSClientConfigTest.class,
ContentletIndexAPIImplMigrationIntegrationTest.class,
ContentletIndexAPIImplPhaseSwitchIntegrationTest.class,
+ ContentletDataGenPhaseControlIT.class,
OSSearchAPIImplIntegrationTest.class
})
public class OpenSearchUpgradeSuite {
diff --git a/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletDataGenPhaseControlIT.java b/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletDataGenPhaseControlIT.java
new file mode 100644
index 000000000000..ff0d42dbd6dc
--- /dev/null
+++ b/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ContentletDataGenPhaseControlIT.java
@@ -0,0 +1,324 @@
+package com.dotcms.content.elasticsearch.business;
+
+import static com.dotcms.content.index.IndexConfigHelper.MigrationPhase.FLAG_KEY;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assume.assumeFalse;
+
+import com.dotcms.DataProviderWeldRunner;
+import com.dotcms.IntegrationTestBase;
+import com.dotcms.content.elasticsearch.util.RestHighLevelClientProvider;
+import com.dotcms.content.index.IndexConfigHelper.MigrationPhase;
+import com.dotcms.content.index.VersionedIndices;
+import com.dotcms.content.index.opensearch.ContentletIndexOperationsOS;
+import com.dotcms.content.index.opensearch.OSClientProvider;
+import com.dotcms.contenttype.model.type.ContentType;
+import com.dotcms.datagen.ContentTypeDataGen;
+import com.dotcms.datagen.ContentletDataGen;
+import com.dotcms.datagen.ContentletDataGen.TargetStore;
+import com.dotcms.util.IntegrationTestInitService;
+import com.dotmarketing.business.APILocator;
+import com.dotmarketing.portlets.contentlet.model.Contentlet;
+import com.dotmarketing.portlets.contentlet.model.IndexPolicy;
+import com.dotmarketing.util.Config;
+import com.dotmarketing.util.Logger;
+import io.vavr.control.Try;
+import java.util.Optional;
+import java.util.function.BooleanSupplier;
+import javax.enterprise.context.ApplicationScoped;
+import javax.inject.Inject;
+import org.elasticsearch.action.get.GetRequest;
+import org.elasticsearch.client.RequestOptions;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.opensearch.client.opensearch.core.CountRequest;
+import org.opensearch.client.opensearch.indices.RefreshRequest;
+
+/**
+ * Integration tests for the migration-phase / store control added to {@link ContentletDataGen}
+ * (issue #36266).
+ *
+ *
What this verifies
+ * That a contentlet created through {@link ContentletDataGen} lands in the search store(s)
+ * dictated by the per-checkin phase override — ElasticSearch only, OpenSearch only, or both —
+ * and that the override restores the previously-configured migration phase afterward.
+ *
+ * How it asserts placement
+ * Both stores are bootstrapped once in {@link #setUp()} (phase 1 +
+ * {@link ContentletIndexAPI#checkAndInitializeIndex()}), so an ES and an OS working
+ * index always exist. Each test then creates a single contentlet with a per-checkin override and
+ * checks whether that exact document ({@code identifier_languageId_variantId})
+ * exists in each provider's working index, queried directly by {@code _id}:
+ * {@link RestHighLevelClientProvider} for ES and the OpenSearch client for OS. Targeting the
+ * document by id on a known physical index (rather than the high-level read path) is noise-free
+ * and sidesteps the {@code inferIndexToHit} bootstrap gap that affects the suite.
+ *
+ * Single-cluster vs two-cluster
+ * Scenarios that write content through the ES leg (ES-only and dual-write) require a
+ * separate ES cluster: the legacy ES {@code RestHighLevelClient} cannot parse an
+ * OpenSearch 3.x content bulk-write response, so it cannot index content against an OS backend.
+ * In the single-cluster {@code opensearch-upgrade} profile ({@code DOT_ES_ENDPOINTS == OS_ENDPOINTS})
+ * those tests skip themselves via {@link org.junit.Assume}. The OS-only placement test and the
+ * per-checkin restore test run in both single- and two-cluster setups.
+ *
+ * Run command
+ *
+ * ./mvnw verify -pl :dotcms-integration \
+ * -Dcoreit.test.skip=false \
+ * -Dopensearch.upgrade.test=true \
+ * -Dit.test=ContentletDataGenPhaseControlIT
+ *
+ *
+ * @author Fabrizzio Araya
+ */
+@ApplicationScoped
+@RunWith(DataProviderWeldRunner.class)
+public class ContentletDataGenPhaseControlIT extends IntegrationTestBase {
+
+ private static final String SEPARATE_CLUSTERS_REQUIRED =
+ "ES content writes require a separate ES cluster — the legacy ES client cannot index "
+ + "content against an OpenSearch 3.x backend (single-cluster opensearch-upgrade profile)";
+
+ /** Poll budget for asynchronously-applied shadow writes to become visible. */
+ private static final int AWAIT_ATTEMPTS = 50;
+ private static final long AWAIT_SLEEP_MS = 100L;
+
+ @Inject
+ private ContentletIndexOperationsOS opsOS;
+
+ @Inject
+ private OSClientProvider clientProvider;
+
+ // ── Resolved active working-index physical names (per provider) ──────────
+ private String esPhysicalWorking;
+ private String osPhysicalWorking;
+
+ // ── Dedicated content type so generated contentlets are isolated ─────────
+ private ContentType contentType;
+
+ // ── Saved state restored in @After ───────────────────────────────────────
+ @SuppressWarnings("deprecation")
+ private IndiciesInfo savedEsInfo;
+ private Optional savedOsIndices;
+ private String savedPhase;
+
+ @BeforeClass
+ public static void prepare() throws Exception {
+ IntegrationTestInitService.getInstance().init();
+ }
+
+ @Before
+ public void setUp() throws Exception {
+ savedPhase = Config.getStringProperty(FLAG_KEY, null);
+ savedEsInfo = APILocator.getIndiciesAPI().loadIndicies();
+ savedOsIndices = APILocator.getVersionedIndicesAPI().loadDefaultVersionedIndices();
+
+ // Bootstrap BOTH stores so an ES and an OS working index exist for the whole test.
+ // Phase 1 (dual-write) makes checkAndInitializeIndex() create + register the OS index set
+ // alongside the already-present ES one.
+ setPhase(MigrationPhase.PHASE_1_DUAL_WRITE_ES_READS);
+ contentletIndexAPI().checkAndInitializeIndex();
+ APILocator.getVersionedIndicesAPI().clearCache();
+
+ esPhysicalWorking = APILocator.getIndiciesAPI().loadIndicies().getWorking();
+ osPhysicalWorking = APILocator.getVersionedIndicesAPI()
+ .loadDefaultVersionedIndices()
+ .flatMap(VersionedIndices::working)
+ .orElseThrow(() -> new IllegalStateException(
+ "OS working index not bootstrapped — cannot verify store placement"));
+
+ contentType = new ContentTypeDataGen().nextPersisted();
+
+ Logger.info(this, "setUp — ES working: " + esPhysicalWorking
+ + " | OS working: " + osPhysicalWorking);
+ }
+
+ @After
+ public void tearDown() {
+ Try.run(() -> ContentTypeDataGen.remove(contentType));
+ Config.setProperty(FLAG_KEY, savedPhase);
+ Try.run(() -> APILocator.getIndiciesAPI().point(savedEsInfo))
+ .onFailure(e -> Logger.warn(this, "tearDown: restore ES indices info failed: " + e.getMessage()));
+ savedOsIndices.ifPresent(v ->
+ Try.run(() -> APILocator.getVersionedIndicesAPI().saveIndices(v))
+ .onFailure(e -> Logger.warn(this, "tearDown: restore OS versioned indices failed: " + e.getMessage())));
+ APILocator.getVersionedIndicesAPI().clearCache();
+ }
+
+ // =========================================================================
+ // Store-placement — store-oriented API (targetStore)
+ // =========================================================================
+
+ /**
+ * Given: a data-gen with {@code targetStore(BOTH)} (dual-write).
+ * When : a contentlet is persisted.
+ * Then : the document is present in BOTH the ES and OS working indices.
+ */
+ @Test
+ public void test_targetStoreBoth_indexesInBothStores() {
+ assumeFalse(SEPARATE_CLUSTERS_REQUIRED, esSameAsOs());
+
+ final String docId = docId(newGen().targetStore(TargetStore.BOTH).nextPersisted());
+
+ assertTrue("BOTH: document must be present in the ES working index",
+ awaitDoc(() -> esHasDoc(docId)));
+ assertTrue("BOTH: document must be present in the OS working index",
+ awaitDoc(() -> osHasDoc(docId)));
+ Logger.info(this, "✅ targetStore(BOTH) — indexed into ES and OS");
+ }
+
+ /**
+ * Given: a data-gen with {@code targetStore(OS)} (OpenSearch only, phase 3).
+ * When : a contentlet is persisted.
+ * Then : the document is present in the OS working index and absent from the ES one.
+ */
+ @Test
+ public void test_targetStoreOs_indexesInOsOnly() {
+ final String docId = docId(newGen().targetStore(TargetStore.OS).nextPersisted());
+
+ assertTrue("OS-only: document must be present in the OS working index",
+ awaitDoc(() -> osHasDoc(docId)));
+ refreshAll();
+ assertFalse("OS-only: document must be absent from the ES working index",
+ esHasDoc(docId));
+ Logger.info(this, "✅ targetStore(OS) — indexed into OS only");
+ }
+
+ /**
+ * Given: a data-gen with {@code targetStore(ES)} (ElasticSearch only, phase 0).
+ * When : a contentlet is persisted.
+ * Then : the document is present in the ES working index and absent from the OS one.
+ */
+ @Test
+ public void test_targetStoreEs_indexesInEsOnly() {
+ assumeFalse(SEPARATE_CLUSTERS_REQUIRED, esSameAsOs());
+
+ final String docId = docId(newGen().targetStore(TargetStore.ES).nextPersisted());
+
+ assertTrue("ES-only: document must be present in the ES working index",
+ awaitDoc(() -> esHasDoc(docId)));
+ refreshAll();
+ assertFalse("ES-only: document must be absent from the OS working index",
+ osHasDoc(docId));
+ Logger.info(this, "✅ targetStore(ES) — indexed into ES only");
+ }
+
+ // =========================================================================
+ // Store-placement — phase-oriented API (migrationPhase, the primary surface)
+ // =========================================================================
+
+ /**
+ * Given: a data-gen with {@code migrationPhase(PHASE_1_DUAL_WRITE_ES_READS)}.
+ * When : a contentlet is persisted.
+ * Then : the document fans out to BOTH stores — exercising the real phase router.
+ */
+ @Test
+ public void test_migrationPhaseDualWrite_indexesInBothStores() {
+ assumeFalse(SEPARATE_CLUSTERS_REQUIRED, esSameAsOs());
+
+ final String docId = docId(
+ newGen().migrationPhase(MigrationPhase.PHASE_1_DUAL_WRITE_ES_READS).nextPersisted());
+
+ assertTrue("phase 1: document must be present in the ES working index",
+ awaitDoc(() -> esHasDoc(docId)));
+ assertTrue("phase 1: document must be present in the OS working index",
+ awaitDoc(() -> osHasDoc(docId)));
+ Logger.info(this, "✅ migrationPhase(PHASE_1) — dual-write fan-out");
+ }
+
+ // =========================================================================
+ // Per-checkin scope — the override restores the prior phase
+ // =========================================================================
+
+ /**
+ * Given: the global migration phase is set to PHASE_2 and the data-gen overrides it to
+ * PHASE_3 for a single persist.
+ * When : the contentlet is persisted.
+ * Then : after persistence the global phase is back to PHASE_2 — the override is scoped to
+ * the checkin and restores the previously-configured value (not blindly cleared).
+ */
+ @Test
+ public void test_override_restoresPriorPhaseAfterPersist() {
+ setPhase(MigrationPhase.PHASE_2_DUAL_WRITE_OS_READS);
+
+ newGen().migrationPhase(MigrationPhase.PHASE_3_OPENSEARCH_ONLY).nextPersisted();
+
+ assertEquals("Override must restore the prior phase after the checkin",
+ MigrationPhase.PHASE_2_DUAL_WRITE_OS_READS, MigrationPhase.current());
+ Logger.info(this, "✅ per-checkin override restored prior phase (PHASE_2)");
+ }
+
+ // =========================================================================
+ // Helpers
+ // =========================================================================
+
+ private ContentletDataGen newGen() {
+ // skipValidation avoids required-field failures for the bare generated content type;
+ // WAIT_FOR makes the primary write synchronous so the document is observable right after persist.
+ return new ContentletDataGen(contentType.id())
+ .skipValidation(true)
+ .setPolicy(IndexPolicy.WAIT_FOR);
+ }
+
+ /** The ES/OS document {@code _id} dotCMS assigns to an indexed contentlet version. */
+ private static String docId(final Contentlet contentlet) {
+ return contentlet.getIdentifier() + "_" + contentlet.getLanguageId()
+ + "_" + contentlet.getVariantId();
+ }
+
+ /** {@code true} when the ES working index holds a document with the given {@code _id}. */
+ private boolean esHasDoc(final String docId) {
+ return Try.of(() -> RestHighLevelClientProvider.getInstance().getClient()
+ .exists(new GetRequest(esPhysicalWorking, docId), RequestOptions.DEFAULT))
+ .getOrElse(false);
+ }
+
+ /** {@code true} when the OS working index holds a document with the given {@code _id}. */
+ private boolean osHasDoc(final String docId) {
+ return Try.of(() -> clientProvider.getClient().count(CountRequest.of(
+ r -> r.index(osPhysicalWorking).query(q -> q.ids(i -> i.values(docId)))))
+ .count() > 0L)
+ .getOrElse(false);
+ }
+
+ /** Refreshes the cluster, then polls {@code present} until it holds or the budget is exhausted. */
+ private boolean awaitDoc(final BooleanSupplier present) {
+ for (int i = 0; i < AWAIT_ATTEMPTS; i++) {
+ refreshAll();
+ if (present.getAsBoolean()) {
+ return true;
+ }
+ Try.run(() -> Thread.sleep(AWAIT_SLEEP_MS));
+ }
+ return present.getAsBoolean();
+ }
+
+ /** Refresh the whole cluster so freshly-written docs are searchable (single-cluster profile). */
+ private void refreshAll() {
+ Try.run(() -> clientProvider.getClient().indices().refresh(RefreshRequest.of(r -> r)))
+ .onFailure(e -> Logger.warn(this, "refreshAll failed: " + e.getMessage()));
+ }
+
+ private static void setPhase(final MigrationPhase phase) {
+ Config.setProperty(FLAG_KEY, String.valueOf(phase.ordinal()));
+ }
+
+ /**
+ * {@code true} when the ES and OS clients point at the same cluster endpoint — the
+ * single-cluster {@code opensearch-upgrade} profile. Tests requiring a real, separate ES
+ * cluster skip themselves via {@link org.junit.Assume#assumeFalse}.
+ */
+ private static boolean esSameAsOs() {
+ final String esEndpoint = Config.getStringProperty("DOT_ES_ENDPOINTS", "http://localhost:9207");
+ final String osEndpoint = Config.getStringProperty("OS_ENDPOINTS", "http://localhost:9201");
+ return esEndpoint.trim().equalsIgnoreCase(osEndpoint.trim());
+ }
+
+ private static ContentletIndexAPI contentletIndexAPI() {
+ return APILocator.getContentletIndexAPI();
+ }
+}
diff --git a/dotcms-integration/src/test/java/com/dotcms/datagen/ContentletDataGen.java b/dotcms-integration/src/test/java/com/dotcms/datagen/ContentletDataGen.java
index 6c63b9f55ca3..f157bb3a94fb 100644
--- a/dotcms-integration/src/test/java/com/dotcms/datagen/ContentletDataGen.java
+++ b/dotcms-integration/src/test/java/com/dotcms/datagen/ContentletDataGen.java
@@ -1,6 +1,7 @@
package com.dotcms.datagen;
import com.dotcms.business.WrapInTransaction;
+import com.dotcms.content.index.IndexConfigHelper.MigrationPhase;
import com.dotcms.contenttype.model.field.DataTypes;
import com.dotcms.contenttype.model.field.Field;
import com.dotcms.contenttype.model.type.ContentType;
@@ -17,6 +18,7 @@
import com.dotmarketing.portlets.contentlet.model.IndexPolicy;
import com.dotmarketing.portlets.folders.model.Folder;
import com.dotmarketing.portlets.languagesmanager.model.Language;
+import com.dotmarketing.util.Config;
import com.dotmarketing.util.Logger;
import com.dotmarketing.util.UtilMethods;
import com.liferay.portal.model.User;
@@ -29,6 +31,7 @@
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
+import java.util.function.Supplier;
import static com.dotmarketing.business.ModDateTestUtil.updateContentletVersionDate;
@@ -51,6 +54,7 @@ public class ContentletDataGen extends AbstractDataGen {
private Date modDate;
protected String variantId;
private User innerUser;
+ private MigrationPhase overridePhase = null;
public ContentletDataGen(final ContentType contentType) {
@@ -168,6 +172,58 @@ public ContentletDataGen skipValidation(boolean skipValidation) {
return this;
}
+ /**
+ * Coarse-grained target store for the contentlet, expressed independently of the
+ * ES→OS migration phase ordinals. Maps onto a {@link MigrationPhase} internally.
+ */
+ public enum TargetStore {
+ /** ElasticSearch only — maps to {@link MigrationPhase#PHASE_0_MIGRATION_NOT_STARTED}. */
+ ES,
+ /** OpenSearch only — maps to {@link MigrationPhase#PHASE_3_OPENSEARCH_ONLY}. */
+ OS,
+ /** Both stores (dual-write) — maps to {@link MigrationPhase#PHASE_1_DUAL_WRITE_ES_READS}. */
+ BOTH
+ }
+
+ /**
+ * Overrides the active ES→OS {@link MigrationPhase} for the duration of each
+ * persist/publish performed by this data-gen, restoring the prior value afterward
+ * (per-checkin scope). This lets a single test create contentlets under specific
+ * migration phases without leaking the feature-flag state across operations.
+ *
+ * When no override is set the data-gen respects the globally-active phase, so
+ * existing tests are unaffected.
+ *
+ * @param phase the phase to force while persisting; {@code null} clears the override
+ * @return this data-gen for chaining
+ */
+ public ContentletDataGen migrationPhase(final MigrationPhase phase) {
+ this.overridePhase = phase;
+ return this;
+ }
+
+ /**
+ * Convenience over {@link #migrationPhase(MigrationPhase)} that selects the target
+ * store directly instead of a phase ordinal.
+ *
+ * @param store the store(s) the contentlet should be indexed into
+ * @return this data-gen for chaining
+ */
+ public ContentletDataGen targetStore(final TargetStore store) {
+ switch (store) {
+ case ES:
+ this.overridePhase = MigrationPhase.PHASE_0_MIGRATION_NOT_STARTED;
+ break;
+ case OS:
+ this.overridePhase = MigrationPhase.PHASE_3_OPENSEARCH_ONLY;
+ break;
+ case BOTH:
+ this.overridePhase = MigrationPhase.PHASE_1_DUAL_WRITE_ES_READS;
+ break;
+ }
+ return this;
+ }
+
/**
* Creates a new {@link Contentlet} instance kept in memory (not persisted)
* @return Contentlet instance created
@@ -224,7 +280,8 @@ public Contentlet nextPersisted() {
*/
@Override
public Contentlet persist(Contentlet contentlet) {
- final Contentlet checkin = checkin(contentlet, null != policy ? policy : IndexPolicy.FORCE);
+ final Contentlet checkin = withPhaseOverride(
+ () -> checkin(contentlet, null != policy ? policy : IndexPolicy.FORCE));
if (modDate != null) {
updateContentletVersionDate(checkin, modDate);
@@ -240,7 +297,7 @@ public Contentlet persist(Contentlet contentlet) {
* @return
*/
public Contentlet persist(final Contentlet contentlet, final List categories) {
- final Contentlet checkin = checkin(contentlet, categories, getUser());
+ final Contentlet checkin = withPhaseOverride(() -> checkin(contentlet, categories, getUser()));
if (modDate != null) {
updateContentletVersionDate(checkin, modDate);
@@ -308,6 +365,27 @@ private User getUser() {
return user == null ? APILocator.systemUser() : user;
}
+ /**
+ * Runs {@code action} with the migration phase forced to {@link #overridePhase} (if set),
+ * restoring the previously configured {@code FEATURE_FLAG_OPEN_SEARCH_PHASE} value afterward.
+ *
+ * The prior value is captured before the call and restored in a {@code finally} block —
+ * including the case where it was unset (restored to {@code null}) and the case where
+ * {@code action} throws. When no override is configured the action runs unchanged.
+ */
+ private R withPhaseOverride(final Supplier action) {
+ if (overridePhase == null) {
+ return action.get();
+ }
+ final String prior = Config.getStringProperty(MigrationPhase.FLAG_KEY, null);
+ try {
+ Config.setProperty(MigrationPhase.FLAG_KEY, String.valueOf(overridePhase.ordinal()));
+ return action.get();
+ } finally {
+ Config.setProperty(MigrationPhase.FLAG_KEY, prior);
+ }
+ }
+
@WrapInTransaction
public static Contentlet checkin(Contentlet contentlet) {
return checkin(contentlet, IndexPolicy.FORCE);
@@ -451,7 +529,7 @@ public ContentletDataGen modeDate(final Date modDate) {
public Contentlet nextPersistedAndPublish() {
final Contentlet contentlet = nextPersisted();
- publish(contentlet);
+ withPhaseOverride(() -> publish(contentlet));
return contentlet;
}