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; }