diff --git a/dotCMS/src/main/java/com/dotcms/content/index/IndexConfigHelper.java b/dotCMS/src/main/java/com/dotcms/content/index/IndexConfigHelper.java
index 75d5ba472aa1..c62006732beb 100644
--- a/dotCMS/src/main/java/com/dotcms/content/index/IndexConfigHelper.java
+++ b/dotCMS/src/main/java/com/dotcms/content/index/IndexConfigHelper.java
@@ -1,9 +1,12 @@
package com.dotcms.content.index;
+import com.dotcms.content.index.opensearch.OSIndexAPIImpl;
+import com.dotcms.content.index.opensearch.OSIndexAPIImpl.ConnectionFailureKind;
import com.dotcms.content.index.opensearch.OSIndexProperty;
import com.dotcms.featureflag.FeatureFlagName;
import com.dotmarketing.util.Config;
import com.dotmarketing.util.Logger;
+import java.util.Optional;
/**
* Central helper for reading index-layer configuration properties.
@@ -65,6 +68,28 @@ static void logShadowWriteFailure(final Class> clazz,
}
}
+ /**
+ * Returns the operator-facing remediation for a shadow-write failure whose cause is
+ * systemic — a connection, TLS or permission problem that keeps rejecting every write
+ * until it is fixed — or {@link Optional#empty()} when the cause cannot be classified, which is
+ * the signature of a per-document problem (a mapping conflict, a malformed field) that must not
+ * be escalated as a migration blocker.
+ *
+ *
Exists as a neutral seam so callers outside the index layer — the reindex bulk listener,
+ * which deliberately carries no vendor imports — can escalate a systemic rejection without
+ * reaching into the OpenSearch adapter's classifier themselves (issue #36222 follow-up).
+ *
+ * @param failureMessage vendor-reported failure text, or {@code null}
+ * @return {@code KIND (remediation)} for a systemic cause, empty otherwise
+ */
+ static Optional systemicFailureRemediation(final String failureMessage) {
+ final ConnectionFailureKind kind = OSIndexAPIImpl.classifyFailureMessage(failureMessage);
+ if (kind == ConnectionFailureKind.UNKNOWN) {
+ return Optional.empty();
+ }
+ return Optional.of(kind.name() + " (" + kind.remediation() + ")");
+ }
+
// -------------------------------------------------------------------------
// Migration phase
// -------------------------------------------------------------------------
diff --git a/dotCMS/src/main/java/com/dotcms/content/index/opensearch/OSIndexAPIImpl.java b/dotCMS/src/main/java/com/dotcms/content/index/opensearch/OSIndexAPIImpl.java
index a6a5748e8301..aa252a2da505 100644
--- a/dotCMS/src/main/java/com/dotcms/content/index/opensearch/OSIndexAPIImpl.java
+++ b/dotCMS/src/main/java/com/dotcms/content/index/opensearch/OSIndexAPIImpl.java
@@ -723,48 +723,80 @@ public String remediation() {
*/
public static ConnectionFailureKind classifyConnectionError(final Throwable error) {
for (Throwable t = error; t != null; t = t.getCause()) {
- final String type = t.getClass().getName().toLowerCase(Locale.ROOT);
- final String msg = t.getMessage() == null
- ? "" : t.getMessage().toLowerCase(Locale.ROOT);
-
- // TLS / scheme mismatch: SSL handshake errors, or a connection closed with no response
- // (the classic symptom of speaking http:// to an https-only port).
- if (type.contains("sslexception")
- || type.contains("sslhandshake")
- || type.contains("connectionclosedexception")
- || msg.contains("unrecognized ssl message")
- || msg.contains("plaintext")
- || msg.contains("ssl")
- || msg.contains("certificat") // certificate / certification path (PKIX)
- || msg.contains("pkix")
- || msg.contains("connection closed")) {
- return ConnectionFailureKind.TLS_SCHEME_MISMATCH;
- }
- // Authentication / authorization. The status code must not be matched as a bare
- // substring: dotCMS wrapper messages embed the physical index name, whose
- // _yyyyMMddHHmmss timestamp regularly contains the digits 401/403 (e.g. an index
- // created at 12:04:03 → working_20260728120403.os), which would misreport an unrelated
- // failure — a settings-parse or orphan-delete error — as a permission problem and send
- // the operator to change DOT_DOTCMS_CLUSTER_ID for nothing (issue #36222).
- if (AUTH_STATUS_CODE.matcher(msg).find()
- || msg.contains("unauthorized") || msg.contains("forbidden")) {
- return ConnectionFailureKind.AUTH_FORBIDDEN;
- }
- // Host/port unreachable.
- if (type.contains("connectexception")
- || type.contains("unknownhostexception")
- || type.contains("sockettimeoutexception")
- || type.contains("nohttpresponseexception")
- || msg.contains("connection refused")
- || msg.contains("timed out")
- || msg.contains("timeout")
- || msg.contains("unknown host")) {
- return ConnectionFailureKind.UNREACHABLE;
+ final ConnectionFailureKind kind = classify(t.getClass().getName(), t.getMessage());
+ if (kind != ConnectionFailureKind.UNKNOWN) {
+ return kind;
}
}
return ConnectionFailureKind.UNKNOWN;
}
+ /**
+ * Classifies a bare failure message, for rejections that never reach the caller as an
+ * exception. The motivating case is a bulk write: the OpenSearch client reports a per-item
+ * rejection as text on the bulk item (e.g. {@code security_exception: no permissions for
+ * [indices:data/write/bulk[s]] …}) rather than throwing, so a systemic permission problem is
+ * indistinguishable from a per-document problem unless the message itself is classified
+ * (issue #36222 follow-up).
+ *
+ * @param failureMessage the vendor-reported failure text, or {@code null}
+ * @return the inferred {@link ConnectionFailureKind}; never {@code null}
+ */
+ public static ConnectionFailureKind classifyFailureMessage(final String failureMessage) {
+ return classify("", failureMessage);
+ }
+
+ /**
+ * Matching core shared by {@link #classifyConnectionError(Throwable)} (once per link of the
+ * exception chain) and {@link #classifyFailureMessage(String)}.
+ *
+ * @param typeName fully-qualified exception type name, or {@code ""} when classifying a message
+ * @param message the failure message, or {@code null}
+ */
+ private static ConnectionFailureKind classify(final String typeName, final String message) {
+ final String type = typeName == null ? "" : typeName.toLowerCase(Locale.ROOT);
+ final String msg = message == null ? "" : message.toLowerCase(Locale.ROOT);
+
+ // TLS / scheme mismatch: SSL handshake errors, or a connection closed with no response
+ // (the classic symptom of speaking http:// to an https-only port).
+ if (type.contains("sslexception")
+ || type.contains("sslhandshake")
+ || type.contains("connectionclosedexception")
+ || msg.contains("unrecognized ssl message")
+ || msg.contains("plaintext")
+ || msg.contains("ssl")
+ || msg.contains("certificat") // certificate / certification path (PKIX)
+ || msg.contains("pkix")
+ || msg.contains("connection closed")) {
+ return ConnectionFailureKind.TLS_SCHEME_MISMATCH;
+ }
+ // Authentication / authorization. The status code must not be matched as a bare
+ // substring: dotCMS wrapper messages embed the physical index name, whose
+ // _yyyyMMddHHmmss timestamp regularly contains the digits 401/403 (e.g. an index
+ // created at 12:04:03 → working_20260728120403.os), which would misreport an unrelated
+ // failure — a settings-parse or orphan-delete error — as a permission problem and send
+ // the operator to change DOT_DOTCMS_CLUSTER_ID for nothing (issue #36222).
+ // The security plugin's own wording is matched too: a rejected bulk item never carries a
+ // status code, only "security_exception: no permissions for [action] and User [...]".
+ if (AUTH_STATUS_CODE.matcher(msg).find()
+ || msg.contains("unauthorized") || msg.contains("forbidden")
+ || msg.contains("security_exception") || msg.contains("no permissions for")) {
+ return ConnectionFailureKind.AUTH_FORBIDDEN;
+ }
+ // Host/port unreachable.
+ if (type.contains("connectexception")
+ || type.contains("unknownhostexception")
+ || type.contains("sockettimeoutexception")
+ || type.contains("nohttpresponseexception")
+ || msg.contains("connection refused")
+ || msg.contains("timed out")
+ || msg.contains("timeout")
+ || msg.contains("unknown host")) {
+ return ConnectionFailureKind.UNREACHABLE;
+ }
+ return ConnectionFailureKind.UNKNOWN;
+ }
+
// =========================================================================
// Alias management
diff --git a/dotCMS/src/main/java/com/dotmarketing/common/reindex/BulkProcessorListener.java b/dotCMS/src/main/java/com/dotmarketing/common/reindex/BulkProcessorListener.java
index c3420f8491b1..fbedeed0bf46 100644
--- a/dotCMS/src/main/java/com/dotmarketing/common/reindex/BulkProcessorListener.java
+++ b/dotCMS/src/main/java/com/dotmarketing/common/reindex/BulkProcessorListener.java
@@ -12,13 +12,16 @@
import com.dotmarketing.exception.DotDataException;
import com.dotmarketing.util.Config;
import com.dotmarketing.util.Logger;
+import com.dotmarketing.util.UtilMethods;
import com.liferay.util.StringPool;
import io.vavr.control.Try;
import java.util.ArrayList;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
/**
* {@link IndexBulkListener} that handles the business logic before/after reindexing content.
@@ -36,6 +39,16 @@ public class BulkProcessorListener implements IndexBulkListener {
static final List RESERVED_IDS = List.of(Host.SYSTEM_HOST);
+ /** Stand-in used when the vendor reports a failed item without any message. */
+ static final String NO_FAILURE_MESSAGE = "(no failure message reported)";
+
+ /**
+ * Minimum gap between two systemic-rejection escalations for the same cause. Every batch is
+ * rejected while the cause lasts, so without a gap the escalation would flood the log at ERROR
+ * level — the very problem this reporting fixes.
+ */
+ private static final int ESCALATION_EVERY_MILLIS = (int) TimeUnit.MINUTES.toMillis(10);
+
private volatile long contentletsIndexed;
private int lastBatchSize;
@@ -110,11 +123,12 @@ public void beforeBulk(final long executionId, final int actionCount) {
@Override
public void afterBulk(final long executionId, final List results) {
if (shadow) {
- // OS shadow — fire-and-forget; log individual failures for observability only
- results.stream()
- .filter(IndexBulkItemResult::failed)
- .forEach(r -> logShadowWriteFailure(this.getClass(),
- "[OS] Index failure (fire-and-forget): " + r.failureMessage(), null));
+ // OS shadow — fire-and-forget, but summarised: a systemic rejection (permissions, TLS,
+ // unreachable cluster) repeats verbatim for every document in the batch, so logging one
+ // line per failed item buried the log — a real reindex emits hundreds of thousands of
+ // identical entries, hiding every other line including the actionable one below
+ // (observed on issue #36222, TC-056: ~900 identical WARNs in one minute).
+ reportShadowBatchFailures(results);
return;
}
Logger.debug(this.getClass(), "Bulk process completed");
@@ -165,6 +179,140 @@ public void afterBulk(final long executionId, final Throwable failure) {
workingRecords.values().forEach(idx -> handleFailure(idx, msg));
}
+ /**
+ * Runs {@link #logShadowBatchFailures(List)} without ever letting it fail the callback.
+ *
+ * Reporting a shadow failure must never become a failure itself. {@code flush()} of the
+ * OpenSearch adapter invokes this callback inside its own {@code try}, so a throw from here
+ * is re-entered as {@code afterBulk(executionId, throwable)} and logged as "Bulk process
+ * failed entirely" — a defect in the reporting would masquerade as the very condition
+ * this reporting exists to detect, on the signal an operator uses to decide whether the
+ * shadow store may be promoted.
+ *
+ * {@link Throwable}, not {@link Exception}, on purpose: the escalation path reaches
+ * {@code OSIndexAPIImpl} through {@link IndexConfigHelper#systemicFailureRemediation(String)},
+ * so a broken static initialiser in the OpenSearch adapter surfaces as an
+ * {@code ExceptionInInitializerError} / {@code NoClassDefFoundError}. Those are the failures
+ * worth containing here: nothing below catches them either
+ * ({@code CompositeBulkProcessor.close()} catches {@code Exception}, so its shadow-isolation
+ * branch is skipped) and they would reach {@code ReindexThread} as an opaque
+ * "ReindexThread Exception" naming neither OpenSearch nor the shadow store.
+ *
+ * @param results every item result of the completed batch, successful ones included
+ */
+ private void reportShadowBatchFailures(final List results) {
+ try {
+ logShadowBatchFailures(results);
+ } catch (final Throwable t) { // NOSONAR - see javadoc: LinkageError is the case to contain
+ // Deliberately a plain warn: the recovery path must not re-enter the classifier that
+ // may have just failed, and a shadow-write reporting problem is not a reindex problem.
+ Logger.warn(this.getClass(), "[" + provider.name() + "] Unable to report shadow bulk"
+ + " failures — indexing itself is unaffected: " + t, t);
+ }
+ }
+
+ /**
+ * Logs the failed items of a shadow bulk batch as one aggregated entry per distinct failure
+ * message, keeps the per-item detail at {@code DEBUG}, and escalates once when the whole batch
+ * was rejected for a systemic reason.
+ *
+ * @param results every item result of the completed batch, successful ones included
+ */
+ private void logShadowBatchFailures(final List results) {
+ final Map failuresByMessage = summarizeFailures(results);
+ if (failuresByMessage.isEmpty()) {
+ return;
+ }
+ final String tag = "[" + provider.name() + "] ";
+ failuresByMessage.forEach((message, count) -> logShadowWriteFailure(this.getClass(),
+ tag + "Index failure (fire-and-forget): " + count + " of " + results.size()
+ + " item(s) in this batch — " + message, null));
+
+ if (Logger.isDebugEnabled(this.getClass())) {
+ results.stream()
+ .filter(IndexBulkItemResult::failed)
+ .forEach(result -> Logger.debug(this.getClass(), tag + "Failed item id="
+ + result.id() + ": " + result.failureMessage()));
+ }
+
+ // Throttled by cause, not by time alone: the same rejection is reported at most once per
+ // ESCALATION_EVERY_MILLIS, while a different rejection is reported immediately.
+ systemicFailureEscalation(provider, results.size(), failuresByMessage)
+ .ifPresent(escalation -> Logger.errorEvery(this.getClass(),
+ provider.name() + "|" + dominantFailure(failuresByMessage).orElse(""),
+ escalation, ESCALATION_EVERY_MILLIS));
+ }
+
+ /**
+ * Counts the failed items of a batch grouped by failure message, preserving first-seen order.
+ * A systemic cause produces a single entry whose count equals the batch size; a per-document
+ * cause produces several entries, or one entry that covers only part of the batch.
+ *
+ * @param results every item result of the completed batch
+ * @return failure message → number of items that failed with it; empty when nothing failed
+ */
+ static Map summarizeFailures(final List results) {
+ final Map failuresByMessage = new LinkedHashMap<>();
+ for (final IndexBulkItemResult result : results) {
+ if (result.failed()) {
+ final String message = result.failureMessage();
+ failuresByMessage.merge(UtilMethods.isSet(message) ? message : NO_FAILURE_MESSAGE,
+ 1L, Long::sum);
+ }
+ }
+ return failuresByMessage;
+ }
+
+ /**
+ * Builds the actionable message for a shadow batch that was rejected in full for a
+ * systemic reason — the state where the shadow store silently stops receiving writes and
+ * diverges from the authoritative one, which must never be promoted by advancing the migration
+ * phase (issue #36222 follow-up: index creation was already covered by
+ * {@code ContentletIndexAPIImpl.handleOsBootstrapFailure}, the write path was not).
+ *
+ * Deliberately silent unless every item failed: a partial failure is a per-document
+ * problem, and unclassifiable messages (mapping conflicts) are not migration blockers either.
+ * The phase is not reset here — a bulk rejection can be scoped to a single index, so the
+ * decision to halt is left to the operator, with this line and the readiness report as the
+ * signal.
+ *
+ * @param provider the shadow provider that rejected the batch
+ * @param batchSize total items in the batch, successful ones included
+ * @param failuresByMessage output of {@link #summarizeFailures(List)}
+ * @return the escalation message, or empty when this batch does not warrant one
+ */
+ static Optional systemicFailureEscalation(final IndexTag provider, final int batchSize,
+ final Map failuresByMessage) {
+ final long failed = failuresByMessage.values().stream().mapToLong(Long::longValue).sum();
+ if (batchSize <= 0 || failed < batchSize) {
+ return Optional.empty();
+ }
+ return dominantFailure(failuresByMessage)
+ .flatMap(dominant -> IndexConfigHelper
+ .systemicFailureRemediation(dominant)
+ .map(remediation -> "[" + provider.name() + "] EVERY document in this bulk"
+ + " batch (" + batchSize + " item(s)) was rejected by "
+ + provider.name() + " — likelyCause=" + remediation
+ + ". The shadow store is NOT receiving writes and is diverging from"
+ + " the authoritative store, so it must not be promoted: advancing"
+ + " the migration phase would serve reads from a stale index."
+ + " Fix the cause above, then run a full reindex to resynchronise."
+ + " Rejection: " + dominant));
+ }
+
+ /**
+ * The failure message that accounts for most items of the batch. Doubles as the throttle
+ * identity of the escalation, so a change of cause is reported immediately.
+ *
+ * @param failuresByMessage output of {@link #summarizeFailures(List)}
+ * @return the dominant failure message, or empty when nothing failed
+ */
+ static Optional dominantFailure(final Map failuresByMessage) {
+ return failuresByMessage.entrySet().stream()
+ .max(Map.Entry.comparingByValue())
+ .map(Map.Entry::getKey);
+ }
+
static String getMatchingReservedIdIfAny(final String id) {
for (final String reservedId : RESERVED_IDS) {
if (id.contains(reservedId)) {
diff --git a/dotCMS/src/main/java/com/dotmarketing/util/Logger.java b/dotCMS/src/main/java/com/dotmarketing/util/Logger.java
index d2708664c261..80b8e9e53e98 100644
--- a/dotCMS/src/main/java/com/dotmarketing/util/Logger.java
+++ b/dotCMS/src/main/java/com/dotmarketing/util/Logger.java
@@ -244,10 +244,38 @@ public static void warnEvery(final Class cl, final String messageKey, final Stri
}
+ /**
+ * this method will print the message at ERROR level at most once per millis set, keyed by
+ * {@code messageKey}: a repeating condition (e.g. every bulk batch being rejected for the same
+ * reason) is reported without flooding the log, while a different key is reported
+ * immediately. Mirrors {@link #warnEvery(Class, String, String, int)} at ERROR level.
+ *
+ * @param cl the class to attribute the log entry to
+ * @param messageKey identity of the condition; the same key is throttled together
+ * @param message the message to print
+ * @param errorEveryMillis minimum millis between two entries for this key
+ */
+ public static void errorEvery(final Class cl, final String messageKey, final String message,
+ final int errorEveryMillis) {
+
+ if (UtilMethods.isEmpty(messageKey)) {
+ return;
+ }
+ final org.apache.logging.log4j.Logger logger = loadLogger(cl);
+
+ final Long hash = Long.valueOf(Objects.hashCode(messageKey.intern()));
+ final Long expireWhen = logMap.get().get(hash);
+
+ if (expireWhen == null || expireWhen < System.currentTimeMillis()) {
+ logMap.get().put(hash, System.currentTimeMillis() + errorEveryMillis, true);
+ logger.error(message + " (log every " + errorEveryMillis + "ms)");
+ }
+ }
+
/**
* this method will print the message at WARN level every millis set and print the message plus
* whole stack trace if at DEGUG level
- *
+ *
* @param cl
* @param message
* @param ex
diff --git a/dotCMS/src/test/java/com/dotcms/content/index/opensearch/OSIndexAPIImplConnectionClassifyTest.java b/dotCMS/src/test/java/com/dotcms/content/index/opensearch/OSIndexAPIImplConnectionClassifyTest.java
index da4386db2c1d..f1bb7689e48f 100644
--- a/dotCMS/src/test/java/com/dotcms/content/index/opensearch/OSIndexAPIImplConnectionClassifyTest.java
+++ b/dotCMS/src/test/java/com/dotcms/content/index/opensearch/OSIndexAPIImplConnectionClassifyTest.java
@@ -83,6 +83,35 @@ public void securityExceptionStatus403_isAuthForbidden() {
+ " reason=no permissions for [indices:admin/create]] status: 403"))));
}
+ /**
+ * A rejected bulk item never carries a status code: the client reports the cluster's
+ * refusal as plain text on the item. Without matching the security plugin's own wording, a
+ * permission problem that rejects every shadow write would be classified as
+ * {@link ConnectionFailureKind#UNKNOWN} and never escalated (issue #36222 follow-up).
+ */
+ @Test
+ public void bulkItemSecurityException_withoutStatusCode_isAuthForbidden() {
+ assertEquals(ConnectionFailureKind.AUTH_FORBIDDEN,
+ OSIndexAPIImpl.classifyFailureMessage(
+ "security_exception: no permissions for [indices:data/write/bulk[s],"
+ + " indices:data/write/index] and User [name=non-admin,"
+ + " backend_roles=[], requestedTenant=null]"));
+ }
+
+ @Test
+ public void bulkItemMappingFailure_isNotClassified() {
+ // A per-document problem must stay UNKNOWN: it is not a migration blocker, and reporting it
+ // as one would escalate every malformed contentlet.
+ assertEquals(ConnectionFailureKind.UNKNOWN,
+ OSIndexAPIImpl.classifyFailureMessage(
+ "mapper_parsing_exception: failed to parse field [myNumber] of type [long]"));
+ }
+
+ @Test
+ public void nullFailureMessage_isNotClassified() {
+ assertEquals(ConnectionFailureKind.UNKNOWN, OSIndexAPIImpl.classifyFailureMessage(null));
+ }
+
/**
* A dotCMS wrapper message embeds the physical index name, and a {@code _yyyyMMddHHmmss}
* timestamp regularly contains the digits 403 or 401 (here 12:04:03). Matching the status code
diff --git a/dotCMS/src/test/java/com/dotmarketing/common/reindex/BulkProcessorListenerShadowFailureTest.java b/dotCMS/src/test/java/com/dotmarketing/common/reindex/BulkProcessorListenerShadowFailureTest.java
new file mode 100644
index 000000000000..9d7165a3d3c9
--- /dev/null
+++ b/dotCMS/src/test/java/com/dotmarketing/common/reindex/BulkProcessorListenerShadowFailureTest.java
@@ -0,0 +1,219 @@
+package com.dotmarketing.common.reindex;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.dotcms.content.index.IndexTag;
+import com.dotcms.content.index.domain.IndexBulkItemResult;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import org.junit.Test;
+
+/**
+ * Unit tests for how {@link BulkProcessorListener} reports failures of the OpenSearch shadow
+ * bulk leg in dual-write phases (issue #36222 follow-up).
+ *
+ * What this covers
+ * The listener used to log one WARN per failed item and nothing else. When the OS user loses a
+ * permission, every document in every batch is rejected with the same message, which produced
+ * hundreds of thousands of identical lines and — worse — no signal at all that the shadow store had
+ * stopped receiving writes altogether. QA hit exactly that on TC-056: ~900 identical warnings in one
+ * minute, and a reindex that looked successful while OpenSearch received nothing.
+ *
+ * Both helpers under test are pure functions, so no container, cluster or config is needed. The
+ * classification of the rejection text itself lives in the OpenSearch adapter and is exercised by
+ * {@code OSIndexAPIImplConnectionClassifyTest}; here we only assert the escalation policy built on
+ * top of it.
+ *
+ *
+ * ./mvnw test -pl :dotcms-core -Dmaven.build.cache.enabled=false \
+ * -Dtest=BulkProcessorListenerShadowFailureTest
+ *
+ *
+ * @author Fabrizzio Araya
+ */
+public class BulkProcessorListenerShadowFailureTest {
+
+ /** Verbatim rejection emitted by the OpenSearch security plugin for a denied bulk write. */
+ private static final String SECURITY_EXCEPTION =
+ "security_exception: no permissions for [indices:data/write/bulk[s],"
+ + " indices:data/write/index] and User [name=non-admin, backend_roles=[],"
+ + " requestedTenant=null]";
+
+ /** A per-document problem: the batch is healthy, this one contentlet is not. */
+ private static final String MAPPER_PARSING_EXCEPTION =
+ "mapper_parsing_exception: failed to parse field [myNumber] of type [long]";
+
+ private static IndexBulkItemResult failed(final String id, final String message) {
+ return IndexBulkItemResult.builder().id(id).failed(true).failureMessage(message).build();
+ }
+
+ private static IndexBulkItemResult succeeded(final String id) {
+ return IndexBulkItemResult.builder().id(id).failed(false).build();
+ }
+
+ private static List allFailedWith(final int count, final String message) {
+ return IntStream.range(0, count)
+ .mapToObj(i -> failed("id-" + i, message))
+ .collect(Collectors.toList());
+ }
+
+ // ---- summarizeFailures ----------------------------------------------------------------------
+
+ @Test
+ public void identicalFailures_areCollapsedIntoOneEntryWithItsCount() {
+ // The whole point: one log line for 185 rejections, not 185 lines.
+ final Map summary =
+ BulkProcessorListener.summarizeFailures(allFailedWith(185, SECURITY_EXCEPTION));
+
+ assertEquals("Identical rejections must collapse to a single entry", 1, summary.size());
+ assertEquals(Long.valueOf(185L), summary.get(SECURITY_EXCEPTION));
+ }
+
+ @Test
+ public void successfulItems_areNotCounted() {
+ final List results = List.of(
+ succeeded("ok-1"),
+ failed("bad-1", MAPPER_PARSING_EXCEPTION),
+ succeeded("ok-2"));
+
+ final Map summary = BulkProcessorListener.summarizeFailures(results);
+
+ assertEquals(1, summary.size());
+ assertEquals(Long.valueOf(1L), summary.get(MAPPER_PARSING_EXCEPTION));
+ }
+
+ @Test
+ public void distinctFailures_areKeptApart() {
+ final List results = List.of(
+ failed("bad-1", SECURITY_EXCEPTION),
+ failed("bad-2", MAPPER_PARSING_EXCEPTION),
+ failed("bad-3", SECURITY_EXCEPTION));
+
+ final Map summary = BulkProcessorListener.summarizeFailures(results);
+
+ assertEquals(2, summary.size());
+ assertEquals(Long.valueOf(2L), summary.get(SECURITY_EXCEPTION));
+ assertEquals(Long.valueOf(1L), summary.get(MAPPER_PARSING_EXCEPTION));
+ }
+
+ @Test
+ public void missingFailureMessage_doesNotProduceANullKey() {
+ final Map summary =
+ BulkProcessorListener.summarizeFailures(List.of(failed("bad-1", null)));
+
+ assertEquals(Long.valueOf(1L),
+ summary.get(BulkProcessorListener.NO_FAILURE_MESSAGE));
+ }
+
+ @Test
+ public void healthyBatch_producesNothing() {
+ assertTrue(BulkProcessorListener.summarizeFailures(
+ List.of(succeeded("ok-1"), succeeded("ok-2"))).isEmpty());
+ }
+
+ // ---- systemicFailureEscalation -------------------------------------------------------------
+
+ @Test
+ public void wholeBatchRejectedByPermissions_escalates() {
+ final List results = allFailedWith(185, SECURITY_EXCEPTION);
+
+ final Optional escalation = BulkProcessorListener.systemicFailureEscalation(
+ IndexTag.OS, results.size(), BulkProcessorListener.summarizeFailures(results));
+
+ assertTrue("A batch rejected in full for a permission problem must escalate",
+ escalation.isPresent());
+ final String message = escalation.get();
+ assertTrue("The cause must be named so the operator knows what to fix: " + message,
+ message.contains("AUTH_FORBIDDEN"));
+ assertTrue("The batch size must be reported: " + message, message.contains("185"));
+ assertTrue("The consequence — do not promote a diverging store — must be stated: " + message,
+ message.contains("must not be promoted"));
+ assertTrue("The verbatim rejection must be kept for support: " + message,
+ message.contains(SECURITY_EXCEPTION));
+ }
+
+ @Test
+ public void partiallyRejectedBatch_doesNotEscalate() {
+ // One rejected document out of many is a content problem, not a migration blocker — even
+ // when its message would classify as systemic on its own.
+ final List results = List.of(
+ succeeded("ok-1"),
+ succeeded("ok-2"),
+ failed("bad-1", SECURITY_EXCEPTION));
+
+ assertFalse(BulkProcessorListener.systemicFailureEscalation(IndexTag.OS, results.size(),
+ BulkProcessorListener.summarizeFailures(results)).isPresent());
+ }
+
+ @Test
+ public void wholeBatchRejectedByAMappingProblem_doesNotEscalate() {
+ // Every item failing is not enough: a batch of documents that share a broken field is a
+ // content problem, and halting the operator on it would be noise.
+ final List results = allFailedWith(10, MAPPER_PARSING_EXCEPTION);
+
+ assertFalse("An unclassifiable cause must not be reported as a systemic failure",
+ BulkProcessorListener.systemicFailureEscalation(IndexTag.OS, results.size(),
+ BulkProcessorListener.summarizeFailures(results)).isPresent());
+ }
+
+ @Test
+ public void mixedCauses_escalateOnTheDominantOne() {
+ // Realistic shape of a denied batch: the permission rejection dominates, one document also
+ // happens to be malformed. The escalation must name the permission problem.
+ final List results = List.of(
+ failed("bad-1", SECURITY_EXCEPTION),
+ failed("bad-2", SECURITY_EXCEPTION),
+ failed("bad-3", MAPPER_PARSING_EXCEPTION));
+
+ final Optional escalation = BulkProcessorListener.systemicFailureEscalation(
+ IndexTag.OS, results.size(), BulkProcessorListener.summarizeFailures(results));
+
+ assertTrue(escalation.isPresent());
+ assertTrue(escalation.get().contains("AUTH_FORBIDDEN"));
+ }
+
+ @Test
+ public void emptyBatch_doesNotEscalate() {
+ assertFalse(BulkProcessorListener.systemicFailureEscalation(
+ IndexTag.OS, 0, Map.of()).isPresent());
+ }
+
+ // ---- containment of the reporting itself -----------------------------------------------------
+
+ @Test
+ public void aFailureWhileReportingIsContained_andNeverReachesTheCaller() {
+ // Reporting a shadow failure must not become a failure. The OpenSearch adapter calls this
+ // callback inside its own try, so a throw here comes back as afterBulk(Throwable) and is
+ // logged as "Bulk process failed entirely" — a reporting defect would then look exactly like
+ // the shadow store having stopped accepting writes, the signal used to decide whether the
+ // store may be promoted.
+ //
+ // Error, not Exception, on purpose: the escalation path initialises OSIndexAPIImpl, so the
+ // realistic failure is a LinkageError, which neither the adapter's catch(Exception) nor
+ // CompositeBulkProcessor.close()'s shadow-isolation branch would contain.
+ final IndexBulkItemResult poisoned = new IndexBulkItemResult() {
+ @Override
+ public String id() {
+ return "boom";
+ }
+
+ @Override
+ public boolean failed() {
+ throw new NoClassDefFoundError("simulated broken static initialiser");
+ }
+
+ @Override
+ public String failureMessage() {
+ return null;
+ }
+ };
+
+ BulkProcessorListener.forShadowProvider(IndexTag.OS).afterBulk(1L, List.of(poisoned));
+ // Reaching this line is the assertion: the callback returned instead of propagating.
+ }
+}