Repository: elastic/elasticsearch
Pull request #125517: Semantic Text Chunking Indexing Pressure
Author: login: Mikep86
Labels: name: >enhancement
name: :Distributed/Engine
description: Anything around managing Lucene and the Translog in an open shard.
name: :ml
description: Machine learning
name: auto-backport
description: Automatically create backport pull requests when merged
name: :SearchOrg/Relevance
description: Label for the Search (solution/org) Relevance team
name: v8.19.0
name: v9.1.0
Created: 2025-03-24T16:57:38Z | Merged: 2025-04-14T19:55:37Z

We have observed many OOMs due to the memory required to inject chunked inference results for `semantic_text` fields. This PR uses coordinating indexing pressure to account for this memory usage. When indexing pressure memory usage exceeds the threshold set by `indexing_pressure.memory.limit`, chunked inference result injection will be suspended to prevent OOMs.


Diff:
diff --git a/docs/changelog/125517.yaml b/docs/changelog/125517.yaml
new file mode 100644
index 0000000000000..993a32960c876
--- /dev/null
+++ b/docs/changelog/125517.yaml
@@ -0,0 +1,5 @@
+pr: 125517
+summary: Semantic Text Chunking Indexing Pressure
+area: Machine Learning
+type: enhancement
+issues: []
diff --git a/server/src/main/java/org/elasticsearch/index/IndexingPressure.java b/server/src/main/java/org/elasticsearch/index/IndexingPressure.java
index 99164fd4b740e..491fb425964e7 100644
--- a/server/src/main/java/org/elasticsearch/index/IndexingPressure.java
+++ b/server/src/main/java/org/elasticsearch/index/IndexingPressure.java
@@ -157,11 +157,15 @@ public Incremental startIncrementalCoordinating(int operations, long bytes, bool
     }
 
     public Coordinating markCoordinatingOperationStarted(int operations, long bytes, boolean forceExecution) {
-        Coordinating coordinating = new Coordinating(forceExecution);
+        Coordinating coordinating = createCoordinatingOperation(forceExecution);
         coordinating.increment(operations, bytes);
         return coordinating;
     }
 
+    public Coordinating createCoordinatingOperation(boolean forceExecution) {
+        return new Coordinating(forceExecution);
+    }
+
     public class Incremental implements Releasable {
 
         private final AtomicBoolean closed = new AtomicBoolean();
@@ -254,7 +258,7 @@ public Coordinating(boolean forceExecution) {
             this.forceExecution = forceExecution;
         }
 
-        private void increment(int operations, long bytes) {
+        public void increment(int operations, long bytes) {
             assert closed.get() == false;
             long combinedBytes = currentCombinedCoordinatingAndPrimaryBytes.addAndGet(bytes);
             long replicaWriteBytes = currentReplicaBytes.get();
diff --git a/server/src/main/java/org/elasticsearch/node/NodeConstruction.java b/server/src/main/java/org/elasticsearch/node/NodeConstruction.java
index bb9b31620ba6d..0a36cd9114158 100644
--- a/server/src/main/java/org/elasticsearch/node/NodeConstruction.java
+++ b/server/src/main/java/org/elasticsearch/node/NodeConstruction.java
@@ -928,6 +928,8 @@ public Map<String, String> queryFields() {
             metadataCreateIndexService
         );
 
+        final IndexingPressure indexingLimits = new IndexingPressure(settings);
+
         PluginServiceInstances pluginServices = new PluginServiceInstances(
             client,
             clusterService,
@@ -950,7 +952,8 @@ public Map<String, String> queryFields() {
             documentParsingProvider,
             taskManager,
             projectResolver,
-            slowLogFieldProvider
+            slowLogFieldProvider,
+            indexingLimits
         );
 
         Collection<?> pluginComponents = pluginsService.flatMap(plugin -> {
@@ -983,7 +986,6 @@ public Map<String, String> queryFields() {
             .map(TerminationHandlerProvider::handler);
         terminationHandler = getSinglePlugin(terminationHandlers, TerminationHandler.class).orElse(null);
 
-        final IndexingPressure indexingLimits = new IndexingPressure(settings);
         final IncrementalBulkService incrementalBulkService = new IncrementalBulkService(client, indexingLimits);
 
         final ResponseCollectorService responseCollectorService = new ResponseCollectorService(clusterService);
diff --git a/server/src/main/java/org/elasticsearch/node/PluginServiceInstances.java b/server/src/main/java/org/elasticsearch/node/PluginServiceInstances.java
index aeb7646524582..ec58555b70505 100644
--- a/server/src/main/java/org/elasticsearch/node/PluginServiceInstances.java
+++ b/server/src/main/java/org/elasticsearch/node/PluginServiceInstances.java
@@ -20,6 +20,7 @@
 import org.elasticsearch.env.Environment;
 import org.elasticsearch.env.NodeEnvironment;
 import org.elasticsearch.features.FeatureService;
+import org.elasticsearch.index.IndexingPressure;
 import org.elasticsearch.index.SlowLogFieldProvider;
 import org.elasticsearch.indices.IndicesService;
 import org.elasticsearch.indices.SystemIndices;
@@ -55,5 +56,6 @@ public record PluginServiceInstances(
     DocumentParsingProvider documentParsingProvider,
     TaskManager taskManager,
     ProjectResolver projectResolver,
-    SlowLogFieldProvider slowLogFieldProvider
+    SlowLogFieldProvider slowLogFieldProvider,
+    IndexingPressure indexingPressure
 ) implements Plugin.PluginServices {}
diff --git a/server/src/main/java/org/elasticsearch/plugins/Plugin.java b/server/src/main/java/org/elasticsearch/plugins/Plugin.java
index 4a3990e3fd3c3..955acbe55768c 100644
--- a/server/src/main/java/org/elasticsearch/plugins/Plugin.java
+++ b/server/src/main/java/org/elasticsearch/plugins/Plugin.java
@@ -29,6 +29,7 @@
 import org.elasticsearch.features.FeatureService;
 import org.elasticsearch.index.IndexModule;
 import org.elasticsearch.index.IndexSettingProvider;
+import org.elasticsearch.index.IndexingPressure;
 import org.elasticsearch.index.SlowLogFieldProvider;
 import org.elasticsearch.indices.IndicesService;
 import org.elasticsearch.indices.SystemIndices;
@@ -186,6 +187,11 @@ public interface PluginServices {
          * Provider for additional SlowLog fields
          */
         SlowLogFieldProvider slowLogFieldProvider();
+
+        /**
+         * Provider for indexing pressure
+         */
+        IndexingPressure indexingPressure();
     }
 
     /**
diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/InferencePlugin.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/InferencePlugin.java
index f771261ffb031..114d9eaedfa53 100644
--- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/InferencePlugin.java
+++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/InferencePlugin.java
@@ -321,7 +321,13 @@ public Collection<?> createComponents(PluginServices services) {
         }
         inferenceServiceRegistry.set(serviceRegistry);
 
-        var actionFilter = new ShardBulkInferenceActionFilter(services.clusterService(), serviceRegistry, modelRegistry, getLicenseState());
+        var actionFilter = new ShardBulkInferenceActionFilter(
+            services.clusterService(),
+            serviceRegistry,
+            modelRegistry,
+            getLicenseState(),
+            services.indexingPressure()
+        );
         shardBulkInferenceActionFilter.set(actionFilter);
 
         var meterRegistry = services.telemetryProvider().getMeterRegistry();
diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
index dff611592dfae..92d1dc21a8455 100644
--- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
+++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
@@ -29,11 +29,13 @@
 import org.elasticsearch.common.settings.Setting;
 import org.elasticsearch.common.unit.ByteSizeValue;
 import org.elasticsearch.common.util.concurrent.AtomicArray;
+import org.elasticsearch.common.util.concurrent.EsRejectedExecutionException;
 import org.elasticsearch.common.xcontent.XContentHelper;
 import org.elasticsearch.common.xcontent.support.XContentMapValues;
 import org.elasticsearch.core.Nullable;
 import org.elasticsearch.core.Releasable;
 import org.elasticsearch.core.TimeValue;
+import org.elasticsearch.index.IndexingPressure;
 import org.elasticsearch.index.mapper.InferenceMetadataFieldsMapper;
 import org.elasticsearch.inference.ChunkInferenceInput;
 import org.elasticsearch.inference.ChunkedInference;
@@ -109,18 +111,21 @@ public class ShardBulkInferenceActionFilter implements MappedActionFilter {
     private final InferenceServiceRegistry inferenceServiceRegistry;
     private final ModelRegistry modelRegistry;
     private final XPackLicenseState licenseState;
+    private final IndexingPressure indexingPressure;
     private volatile long batchSizeInBytes;
 
     public ShardBulkInferenceActionFilter(
         ClusterService clusterService,
         InferenceServiceRegistry inferenceServiceRegistry,
         ModelRegistry modelRegistry,
-        XPackLicenseState licenseState
+        XPackLicenseState licenseState,
+        IndexingPressure indexingPressure
     ) {
         this.clusterService = clusterService;
         this.inferenceServiceRegistry = inferenceServiceRegistry;
         this.modelRegistry = modelRegistry;
         this.licenseState = licenseState;
+        this.indexingPressure = indexingPressure;
         this.batchSizeInBytes = INDICES_INFERENCE_BATCH_SIZE.get(clusterService.getSettings()).getBytes();
         clusterService.getClusterSettings().addSettingsUpdateConsumer(INDICES_INFERENCE_BATCH_SIZE, this::setBatchSize);
     }
@@ -146,8 +151,15 @@ public <Request extends ActionRequest, Response extends ActionResponse> void app
             BulkShardRequest bulkShardRequest = (BulkShardRequest) request;
             var fieldInferenceMetadata = bulkShardRequest.consumeInferenceFieldMap();
             if (fieldInferenceMetadata != null && fieldInferenceMetadata.isEmpty() == false) {
-                Runnable onInferenceCompletion = () -> chain.proceed(task, action, request, listener);
-                processBulkShardRequest(fieldInferenceMetadata, bulkShardRequest, onInferenceCompletion);
+                // Maintain coordinating indexing pressure from inference until the indexing operations are complete
+                IndexingPressure.Coordinating coordinatingIndexingPressure = indexingPressure.createCoordinatingOperation(false);
+                Runnable onInferenceCompletion = () -> chain.proceed(
+                    task,
+                    action,
+                    request,
+                    ActionListener.releaseAfter(listener, coordinatingIndexingPressure)
+                );
+                processBulkShardRequest(fieldInferenceMetadata, bulkShardRequest, onInferenceCompletion, coordinatingIndexingPressure);
                 return;
             }
         }
@@ -157,12 +169,14 @@ public <Request extends ActionRequest, Response extends ActionResponse> void app
     private void processBulkShardRequest(
         Map<String, InferenceFieldMetadata> fieldInferenceMap,
         BulkShardRequest bulkShardRequest,
-        Runnable onCompletion
+        Runnable onCompletion,
+        IndexingPressure.Coordinating coordinatingIndexingPressure
     ) {
         final ProjectMetadata project = clusterService.state().getMetadata().getProject();
         var index = project.index(bulkShardRequest.index());
         boolean useLegacyFormat = InferenceMetadataFieldsMapper.isEnabled(index.getSettings()) == false;
-        new AsyncBulkShardInferenceAction(useLegacyFormat, fieldInferenceMap, bulkShardRequest, onCompletion).run();
+        new AsyncBulkShardInferenceAction(useLegacyFormat, fieldInferenceMap, bulkShardRequest, onCompletion, coordinatingIndexingPressure)
+            .run();
     }
 
     private record InferenceProvider(InferenceService service, Model model) {}
@@ -232,18 +246,21 @@ private class AsyncBulkShardInferenceAction implements Runnable {
         private final BulkShardRequest bulkShardRequest;
         private final Runnable onCompletion;
         private final AtomicArray<FieldInferenceResponseAccumulator> inferenceResults;
+        private final IndexingPressure.Coordinating coordinatingIndexingPressure;
 
         private AsyncBulkShardInferenceAction(
             boolean useLegacyFormat,
             Map<String, InferenceFieldMetadata> fieldInferenceMap,
             BulkShardRequest bulkShardRequest,
-            Runnable onCompletion
+            Runnable onCompletion,
+            IndexingPressure.Coordinating coordinatingIndexingPressure
         ) {
             this.useLegacyFormat = useLegacyFormat;
             this.fieldInferenceMap = fieldInferenceMap;
             this.bulkShardRequest = bulkShardRequest;
             this.inferenceResults = new AtomicArray<>(bulkShardRequest.items().length);
             this.onCompletion = onCompletion;
+            this.coordinatingIndexingPressure = coordinatingIndexingPressure;
         }
 
         @Override
@@ -431,9 +448,9 @@ public void onFailure(Exception exc) {
          */
         private long addFieldInferenceRequests(BulkItemRequest item, int itemIndex, Map<String, List<FieldInferenceRequest>> requestsMap) {
             boolean isUpdateRequest = false;
-            final IndexRequest indexRequest;
+            final IndexRequestWithIndexingPressure indexRequest;
             if (item.request() instanceof IndexRequest ir) {
-                indexRequest = ir;
+                indexRequest = new IndexRequestWithIndexingPressure(ir);
             } else if (item.request() instanceof UpdateRequest updateRequest) {
                 isUpdateRequest = true;
                 if (updateRequest.script() != null) {
@@ -447,13 +464,13 @@ private long addFieldInferenceRequests(BulkItemRequest item, int itemIndex, Map<
                     );
                     return 0;
                 }
-                indexRequest = updateRequest.doc();
+                indexRequest = new IndexRequestWithIndexingPressure(updateRequest.doc());
             } else {
                 // ignore delete request
                 return 0;
             }
 
-            final Map<String, Object> docMap = indexRequest.sourceAsMap();
+            final Map<String, Object> docMap = indexRequest.getIndexRequest().sourceAsMap();
             long inputLength = 0;
             for (var entry : fieldInferenceMap.values()) {
                 String field = entry.getName();
@@ -489,6 +506,10 @@ private long addFieldInferenceRequests(BulkItemRequest item, int itemIndex, Map<
                          * This ensures that the field is treated as intentionally cleared,
                          * preventing any unintended carryover of prior inference results.
                          */
+                        if (incrementIndexingPressure(indexRequest, itemIndex) == false) {
+                            return inputLength;
+                        }
+
                         var slot = ensureResponseAccumulatorSlot(itemIndex);
                         slot.addOrUpdateResponse(
                             new FieldInferenceResponse(field, sourceField, null, order++, 0, null, EMPTY_CHUNKED_INFERENCE)
@@ -510,6 +531,7 @@ private long addFieldInferenceRequests(BulkItemRequest item, int itemIndex, Map<
                         }
                         continue;
                     }
+
                     var slot = ensureResponseAccumulatorSlot(itemIndex);
                     final List<String> values;
                     try {
@@ -527,7 +549,10 @@ private long addFieldInferenceRequests(BulkItemRequest item, int itemIndex, Map<
                     List<FieldInferenceRequest> requests = requestsMap.computeIfAbsent(inferenceId, k -> new ArrayList<>());
                     int offsetAdjustment = 0;
                     for (String v : values) {
-                        inputLength += v.length();
+                        if (incrementIndexingPressure(indexRequest, itemIndex) == false) {
+                            return inputLength;
+                        }
+
                         if (v.isBlank()) {
                             slot.addOrUpdateResponse(
                                 new FieldInferenceResponse(field, sourceField, v, order++, 0, null, EMPTY_CHUNKED_INFERENCE)
@@ -536,6 +561,7 @@ private long addFieldInferenceRequests(BulkItemRequest item, int itemIndex, Map<
                             requests.add(
                                 new FieldInferenceRequest(itemIndex, field, sourceField, v, order++, offsetAdjustment, chunkingSettings)
                             );
+                            inputLength += v.length();
                         }
 
                         // When using the inference metadata fields format, all the input values are concatenated so that the
@@ -545,9 +571,54 @@ private long addFieldInferenceRequests(BulkItemRequest item, int itemIndex, Map<
                     }
                 }
             }
+
             return inputLength;
         }
 
+        private static class IndexRequestWithIndexingPressure {
+            private final IndexRequest indexRequest;
+            private boolean indexingPressureIncremented;
+
+            private IndexRequestWithIndexingPressure(IndexRequest indexRequest) {
+                this.indexRequest = indexRequest;
+                this.indexingPressureIncremented = false;
+            }
+
+            private IndexRequest getIndexRequest() {
+                return indexRequest;
+            }
+
+            private boolean isIndexingPressureIncremented() {
+                return indexingPressureIncremented;
+            }
+
+            private void setIndexingPressureIncremented() {
+                this.indexingPressureIncremented = true;
+            }
+        }
+
+        private boolean incrementIndexingPressure(IndexRequestWithIndexingPressure indexRequest, int itemIndex) {
+            boolean success = true;
+            if (indexRequest.isIndexingPressureIncremented() == false) {
+                try {
+                    // Track operation count as one operation per document source update
+                    coordinatingIndexingPressure.increment(1, indexRequest.getIndexRequest().source().ramBytesUsed());
+                    indexRequest.setIndexingPressureIncremented();
+                } catch (EsRejectedExecutionException e) {
+                    addInferenceResponseFailure(
+                        itemIndex,
+                        new InferenceException(
+                            "Insufficient memory available to update source on document [" + indexRequest.getIndexRequest().id() + "]",
+                            e
+                        )
+                    );
+                    success = false;
+                }
+            }
+
+            return success;
+        }
+
         private FieldInferenceResponseAccumulator ensureResponseAccumulatorSlot(int id) {
             FieldInferenceResponseAccumulator acc = inferenceResults.get(id);
             if (acc == null) {
@@ -624,6 +695,7 @@ private void applyInferenceResponses(BulkItemRequest item, FieldInferenceRespons
                 inferenceFieldsMap.put(fieldName, result);
             }
 
+            BytesReference originalSource = indexRequest.source();
             if (useLegacyFormat) {
                 var newDocMap = indexRequest.sourceAsMap();
                 for (var entry : inferenceFieldsMap.entrySet()) {
@@ -636,6 +708,23 @@ private void applyInferenceResponses(BulkItemRequest item, FieldInferenceRespons
                     indexRequest.source(builder);
                 }
             }
+            long modifiedSourceSize = indexRequest.source().ramBytesUsed();
+
+            // Add the indexing pressure from the source modifications.
+            // Don't increment operation count because we count one source update as one operation, and we already accounted for those
+            // in addFieldInferenceRequests.
+            try {
+                coordinatingIndexingPressure.increment(0, modifiedSourceSize - originalSource.ramBytesUsed());
+            } catch (EsRejectedExecutionException e) {
+                indexRequest.source(originalSource, indexRequest.getContentType());
+                item.abort(
+                    item.index(),
+                    new InferenceException(
+                        "Insufficient memory available to insert inference results into document [" + indexRequest.id() + "]",
+                        e
+                    )
+                );
+            }
         }
     }
 
diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilterTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilterTests.java
index 905acb7363f7b..f592774b7a356 100644
--- a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilterTests.java
+++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilterTests.java
@@ -15,6 +15,7 @@
 import org.elasticsearch.action.bulk.BulkItemRequest;
 import org.elasticsearch.action.bulk.BulkItemResponse;
 import org.elasticsearch.action.bulk.BulkShardRequest;
+import org.elasticsearch.action.bulk.BulkShardResponse;
 import org.elasticsearch.action.bulk.TransportShardBulkAction;
 import org.elasticsearch.action.index.IndexRequest;
 import org.elasticsearch.action.support.ActionFilterChain;
@@ -27,19 +28,24 @@
 import org.elasticsearch.cluster.metadata.Metadata;
 import org.elasticsearch.cluster.metadata.ProjectMetadata;
 import org.elasticsearch.cluster.service.ClusterService;
+import org.elasticsearch.common.CheckedBiFunction;
 import org.elasticsearch.common.Strings;
+import org.elasticsearch.common.bytes.BytesReference;
 import org.elasticsearch.common.settings.ClusterSettings;
 import org.elasticsearch.common.settings.Settings;
 import org.elasticsearch.common.unit.ByteSizeValue;
+import org.elasticsearch.common.util.concurrent.EsRejectedExecutionException;
 import org.elasticsearch.common.xcontent.XContentHelper;
 import org.elasticsearch.common.xcontent.support.XContentMapValues;
 import org.elasticsearch.index.IndexVersion;
+import org.elasticsearch.index.IndexingPressure;
 import org.elasticsearch.index.mapper.InferenceMetadataFieldsMapper;
 import org.elasticsearch.index.shard.ShardId;
 import org.elasticsearch.inference.ChunkInferenceInput;
 import org.elasticsearch.inference.ChunkedInference;
 import org.elasticsearch.inference.InferenceService;
 import org.elasticsearch.inference.InferenceServiceRegistry;
+import org.elasticsearch.inference.MinimalServiceSettings;
 import org.elasticsearch.inference.Model;
 import org.elasticsearch.inference.TaskType;
 import org.elasticsearch.inference.UnparsedModel;
@@ -49,6 +55,8 @@
 import org.elasticsearch.test.ESTestCase;
 import org.elasticsearch.threadpool.TestThreadPool;
 import org.elasticsearch.threadpool.ThreadPool;
+import org.elasticsearch.xcontent.XContentBuilder;
+import org.elasticsearch.xcontent.XContentFactory;
 import org.elasticsearch.xcontent.XContentType;
 import org.elasticsearch.xcontent.json.JsonXContent;
 import org.elasticsearch.xpack.core.XPackField;
@@ -73,13 +81,16 @@
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
 
+import static org.elasticsearch.index.IndexingPressure.MAX_COORDINATING_BYTES;
 import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertToXContentEquivalent;
 import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.awaitLatch;
+import static org.elasticsearch.xcontent.ToXContent.EMPTY_PARAMS;
 import static org.elasticsearch.xpack.inference.action.filter.ShardBulkInferenceActionFilter.INDICES_INFERENCE_BATCH_SIZE;
 import static org.elasticsearch.xpack.inference.action.filter.ShardBulkInferenceActionFilter.getIndexRequestOrNull;
 import static org.elasticsearch.xpack.inference.mapper.SemanticTextField.getChunksFieldName;
 import static org.elasticsearch.xpack.inference.mapper.SemanticTextField.getOriginalTextFieldName;
-import static org.elasticsearch.xpack.inference.mapper.SemanticTextFieldTests.randomChunkedInferenceEmbeddingSparse;
+import static org.elasticsearch.xpack.inference.mapper.SemanticTextFieldMapperTests.addSemanticTextInferenceResults;
+import static org.elasticsearch.xpack.inference.mapper.SemanticTextFieldTests.randomChunkedInferenceEmbedding;
 import static org.elasticsearch.xpack.inference.mapper.SemanticTextFieldTests.randomSemanticText;
 import static org.elasticsearch.xpack.inference.mapper.SemanticTextFieldTests.randomSemanticTextInput;
 import static org.elasticsearch.xpack.inference.mapper.SemanticTextFieldTests.semanticTextFieldFromChunkedInferenceResults;
@@ -88,15 +99,24 @@
 import static org.hamcrest.Matchers.equalTo;
 import static org.hamcrest.Matchers.instanceOf;
 import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.longThat;
 import static org.mockito.Mockito.any;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 public class ShardBulkInferenceActionFilterTests extends ESTestCase {
     private static final Object EXPLICIT_NULL = new Object();
+    private static final IndexingPressure NOOP_INDEXING_PRESSURE = new NoopIndexingPressure();
 
     private final boolean useLegacyFormat;
     private ThreadPool threadPool;
@@ -122,7 +142,7 @@ public void tearDownThreadPool() throws Exception {
 
     @SuppressWarnings({ "unchecked", "rawtypes" })
     public void testFilterNoop() throws Exception {
-        ShardBulkInferenceActionFilter filter = createFilter(threadPool, Map.of(), useLegacyFormat, true);
+        ShardBulkInferenceActionFilter filter = createFilter(threadPool, Map.of(), NOOP_INDEXING_PRESSURE, useLegacyFormat, true);
         CountDownLatch chainExecuted = new CountDownLatch(1);
         ActionFilterChain actionFilterChain = (task, action, request, listener) -> {
             try {
@@ -148,7 +168,7 @@ public void testFilterNoop() throws Exception {
     @SuppressWarnings({ "unchecked", "rawtypes" })
     public void testLicenseInvalidForInference() throws InterruptedException {
         StaticModel model = StaticModel.createRandomInstance();
-        ShardBulkInferenceActionFilter filter = createFilter(threadPool, Map.of(), useLegacyFormat, false);
+        ShardBulkInferenceActionFilter filter = createFilter(threadPool, Map.of(), NOOP_INDEXING_PRESSURE, useLegacyFormat, false);
         CountDownLatch chainExecuted = new CountDownLatch(1);
         ActionFilterChain actionFilterChain = (task, action, request, listener) -> {
             try {
@@ -189,6 +209,7 @@ public void testInferenceNotFound() throws Exception {
         ShardBulkInferenceActionFilter filter = createFilter(
             threadPool,
             Map.of(model.getInferenceEntityId(), model),
+            NOOP_INDEXING_PRESSURE,
             useLegacyFormat,
             true
         );
@@ -230,16 +251,17 @@ public void testInferenceNotFound() throws Exception {
 
     @SuppressWarnings({ "unchecked", "rawtypes" })
     public void testItemFailures() throws Exception {
-        StaticModel model = StaticModel.createRandomInstance();
+        StaticModel model = StaticModel.createRandomInstance(TaskType.SPARSE_EMBEDDING);
 
         ShardBulkInferenceActionFilter filter = createFilter(
             threadPool,
             Map.of(model.getInferenceEntityId(), model),
+            NOOP_INDEXING_PRESSURE,
             useLegacyFormat,
             true
         );
         model.putResult("I am a failure", new ChunkedInferenceError(new IllegalArgumentException("boom")));
-        model.putResult("I am a success", randomChunkedInferenceEmbeddingSparse(List.of("I am a success")));
+        model.putResult("I am a success", randomChunkedInferenceEmbedding(model, List.of("I am a success")));
         CountDownLatch chainExecuted = new CountDownLatch(1);
         ActionFilterChain actionFilterChain = (task, action, request, listener) -> {
             try {
@@ -298,13 +320,14 @@ public void testItemFailures() throws Exception {
 
     @SuppressWarnings({ "unchecked", "rawtypes" })
     public void testExplicitNull() throws Exception {
-        StaticModel model = StaticModel.createRandomInstance();
+        StaticModel model = StaticModel.createRandomInstance(TaskType.SPARSE_EMBEDDING);
         model.putResult("I am a failure", new ChunkedInferenceError(new IllegalArgumentException("boom")));
-        model.putResult("I am a success", randomChunkedInferenceEmbeddingSparse(List.of("I am a success")));
+        model.putResult("I am a success", randomChunkedInferenceEmbedding(model, List.of("I am a success")));
 
         ShardBulkInferenceActionFilter filter = createFilter(
             threadPool,
             Map.of(model.getInferenceEntityId(), model),
+            NOOP_INDEXING_PRESSURE,
             useLegacyFormat,
             true
         );
@@ -375,6 +398,7 @@ public void testHandleEmptyInput() throws Exception {
         ShardBulkInferenceActionFilter filter = createFilter(
             threadPool,
             Map.of(model.getInferenceEntityId(), model),
+            NOOP_INDEXING_PRESSURE,
             useLegacyFormat,
             true
         );
@@ -447,7 +471,7 @@ public void testManyRandomDocs() throws Exception {
             modifiedRequests[id] = res[1];
         }
 
-        ShardBulkInferenceActionFilter filter = createFilter(threadPool, inferenceModelMap, useLegacyFormat, true);
+        ShardBulkInferenceActionFilter filter = createFilter(threadPool, inferenceModelMap, NOOP_INDEXING_PRESSURE, useLegacyFormat, true);
         CountDownLatch chainExecuted = new CountDownLatch(1);
         ActionFilterChain actionFilterChain = (task, action, request, listener) -> {
             try {
@@ -477,10 +501,397 @@ public void testManyRandomDocs() throws Exception {
         awaitLatch(chainExecuted, 10, TimeUnit.SECONDS);
     }
 
+    @SuppressWarnings({ "unchecked", "rawtypes" })
+    public void testIndexingPressure() throws Exception {
+        final InstrumentedIndexingPressure indexingPressure = new InstrumentedIndexingPressure(Settings.EMPTY);
+        final StaticModel sparseModel = StaticModel.createRandomInstance(TaskType.SPARSE_EMBEDDING);
+        final StaticModel denseModel = StaticModel.createRandomInstance(TaskType.TEXT_EMBEDDING);
+        final ShardBulkInferenceActionFilter filter = createFilter(
+            threadPool,
+            Map.of(sparseModel.getInferenceEntityId(), sparseModel, denseModel.getInferenceEntityId(), denseModel),
+            indexingPressure,
+            useLegacyFormat,
+            true
+        );
+
+        XContentBuilder doc0Source = IndexRequest.getXContentBuilder(XContentType.JSON, "sparse_field", "a test value");
+        XContentBuilder doc1Source = IndexRequest.getXContentBuilder(XContentType.JSON, "dense_field", "another test value");
+        XContentBuilder doc2Source = IndexRequest.getXContentBuilder(
+            XContentType.JSON,
+            "sparse_field",
+            "a test value",
+            "dense_field",
+            "another test value"
+        );
+        XContentBuilder doc3Source = IndexRequest.getXContentBuilder(
+            XContentType.JSON,
+            "dense_field",
+            List.of("value one", "  ", "value two")
+        );
+        XContentBuilder doc4Source = IndexRequest.getXContentBuilder(XContentType.JSON, "sparse_field", "   ");
+        XContentBuilder doc5Source = XContentFactory.contentBuilder(XContentType.JSON);
+        {
+            doc5Source.startObject();
+            if (useLegacyFormat == false) {
+                doc5Source.field("sparse_field", "a test value");
+            }
+            addSemanticTextInferenceResults(
+                useLegacyFormat,
+                doc5Source,
+                List.of(randomSemanticText(useLegacyFormat, "sparse_field", sparseModel, null, List.of("a test value"), XContentType.JSON))
+            );
+            doc5Source.endObject();
+        }
+        XContentBuilder doc0UpdateSource = IndexRequest.getXContentBuilder(XContentType.JSON, "sparse_field", "an updated value");
+        XContentBuilder doc1UpdateSource = IndexRequest.getXContentBuilder(XContentType.JSON, "dense_field", null);
+
+        CountDownLatch chainExecuted = new CountDownLatch(1);
+        ActionFilterChain actionFilterChain = (task, action, request, listener) -> {
+            try {
+                BulkShardRequest bulkShardRequest = (BulkShardRequest) request;
+                assertNull(bulkShardRequest.getInferenceFieldMap());
+                assertThat(bulkShardRequest.items().length, equalTo(10));
+
+                for (BulkItemRequest item : bulkShardRequest.items()) {
+                    assertNull(item.getPrimaryResponse());
+                }
+
+                IndexingPressure.Coordinating coordinatingIndexingPressure = indexingPressure.getCoordinating();
+                assertThat(coordinatingIndexingPressure, notNullValue());
+                verify(coordinatingIndexingPressure).increment(1, bytesUsed(doc0Source));
+                verify(coordinatingIndexingPressure).increment(1, bytesUsed(doc1Source));
+                verify(coordinatingIndexingPressure).increment(1, bytesUsed(doc2Source));
+                verify(coordinatingIndexingPressure).increment(1, bytesUsed(doc3Source));
+                verify(coordinatingIndexingPressure).increment(1, bytesUsed(doc4Source));
+                verify(coordinatingIndexingPressure).increment(1, bytesUsed(doc0UpdateSource));
+                if (useLegacyFormat == false) {
+                    verify(coordinatingIndexingPressure).increment(1, bytesUsed(doc1UpdateSource));
+                }
+
+                verify(coordinatingIndexingPressure, times(useLegacyFormat ? 6 : 7)).increment(eq(0), longThat(l -> l > 0));
+
+                // Verify that the only times that increment is called are the times verified above
+                verify(coordinatingIndexingPressure, times(useLegacyFormat ? 12 : 14)).increment(anyInt(), anyLong());
+
+                // Verify that the coordinating indexing pressure is maintained through downstream action filters
+                verify(coordinatingIndexingPressure, never()).close();
+
+                // Call the listener once the request is successfully processed, like is done in the production code path
+                listener.onResponse(null);
+            } finally {
+                chainExecuted.countDown();
+            }
+        };
+        ActionListener actionListener = mock(ActionListener.class);
+        Task task = mock(Task.class);
+
+        Map<String, InferenceFieldMetadata> inferenceFieldMap = Map.of(
+            "sparse_field",
+            new InferenceFieldMetadata("sparse_field", sparseModel.getInferenceEntityId(), new String[] { "sparse_field" }, null),
+            "dense_field",
+            new InferenceFieldMetadata("dense_field", denseModel.getInferenceEntityId(), new String[] { "dense_field" }, null)
+        );
+
+        BulkItemRequest[] items = new BulkItemRequest[10];
+        items[0] = new BulkItemRequest(0, new IndexRequest("index").id("doc_0").source(doc0Source));
+        items[1] = new BulkItemRequest(1, new IndexRequest("index").id("doc_1").source(doc1Source));
+        items[2] = new BulkItemRequest(2, new IndexRequest("index").id("doc_2").source(doc2Source));
+        items[3] = new BulkItemRequest(3, new IndexRequest("index").id("doc_3").source(doc3Source));
+        items[4] = new BulkItemRequest(4, new IndexRequest("index").id("doc_4").source(doc4Source));
+        items[5] = new BulkItemRequest(5, new IndexRequest("index").id("doc_5").source(doc5Source));
+        items[6] = new BulkItemRequest(6, new IndexRequest("index").id("doc_6").source("non_inference_field", "yet another test value"));
+        items[7] = new BulkItemRequest(7, new UpdateRequest().doc(new IndexRequest("index").id("doc_0").source(doc0UpdateSource)));
+        items[8] = new BulkItemRequest(8, new UpdateRequest().doc(new IndexRequest("index").id("doc_1").source(doc1UpdateSource)));
+        items[9] = new BulkItemRequest(
+            9,
+            new UpdateRequest().doc(new IndexRequest("index").id("doc_3").source("non_inference_field", "yet another updated value"))
+        );
+
+        BulkShardRequest request = new BulkShardRequest(new ShardId("test", "test", 0), WriteRequest.RefreshPolicy.NONE, items);
+        request.setInferenceFieldMap(inferenceFieldMap);
+        filter.apply(task, TransportShardBulkAction.ACTION_NAME, request, actionListener, actionFilterChain);
+        awaitLatch(chainExecuted, 10, TimeUnit.SECONDS);
+
+        IndexingPressure.Coordinating coordinatingIndexingPressure = indexingPressure.getCoordinating();
+        assertThat(coordinatingIndexingPressure, notNullValue());
+        verify(coordinatingIndexingPressure).close();
+    }
+
+    @SuppressWarnings("unchecked")
+    public void testIndexingPressureTripsOnInferenceRequestGeneration() throws Exception {
+        final InstrumentedIndexingPressure indexingPressure = new InstrumentedIndexingPressure(
+            Settings.builder().put(MAX_COORDINATING_BYTES.getKey(), "1b").build()
+        );
+        final StaticModel sparseModel = StaticModel.createRandomInstance(TaskType.SPARSE_EMBEDDING);
+        final ShardBulkInferenceActionFilter filter = createFilter(
+            threadPool,
+            Map.of(sparseModel.getInferenceEntityId(), sparseModel),
+            indexingPressure,
+            useLegacyFormat,
+            true
+        );
+
+        XContentBuilder doc1Source = IndexRequest.getXContentBuilder(XContentType.JSON, "sparse_field", "bar");
+
+        CountDownLatch chainExecuted = new CountDownLatch(1);
+        ActionFilterChain<BulkShardRequest, BulkShardResponse> actionFilterChain = (task, action, request, listener) -> {
+            try {
+                assertNull(request.getInferenceFieldMap());
+                assertThat(request.items().length, equalTo(3));
+
+                assertNull(request.items()[0].getPrimaryResponse());
+                assertNull(request.items()[2].getPrimaryResponse());
+
+                BulkItemRequest doc1Request = request.items()[1];
+                BulkItemResponse doc1Response = doc1Request.getPrimaryResponse();
+                assertNotNull(doc1Response);
+                assertTrue(doc1Response.isFailed());
+                BulkItemResponse.Failure doc1Failure = doc1Response.getFailure();
+                assertThat(
+                    doc1Failure.getCause().getMessage(),
+                    containsString("Insufficient memory available to update source on document [doc_1]")
+                );
+                assertThat(doc1Failure.getCause().getCause(), instanceOf(EsRejectedExecutionException.class));
+                assertThat(doc1Failure.getStatus(), is(RestStatus.TOO_MANY_REQUESTS));
+
+                IndexRequest doc1IndexRequest = getIndexRequestOrNull(doc1Request.request());
+                assertThat(doc1IndexRequest, notNullValue());
+                assertThat(doc1IndexRequest.source(), equalTo(BytesReference.bytes(doc1Source)));
+
+                IndexingPressure.Coordinating coordinatingIndexingPressure = indexingPressure.getCoordinating();
+                assertThat(coordinatingIndexingPressure, notNullValue());
+                verify(coordinatingIndexingPressure).increment(1, bytesUsed(doc1Source));
+                verify(coordinatingIndexingPressure, times(1)).increment(anyInt(), anyLong());
+
+                // Verify that the coordinating indexing pressure is maintained through downstream action filters
+                verify(coordinatingIndexingPressure, never()).close();
+
+                // Call the listener once the request is successfully processed, like is done in the production code path
+                listener.onResponse(null);
+            } finally {
+                chainExecuted.countDown();
+            }
+        };
+        ActionListener<BulkShardResponse> actionListener = (ActionListener<BulkShardResponse>) mock(ActionListener.class);
+        Task task = mock(Task.class);
+
+        Map<String, InferenceFieldMetadata> inferenceFieldMap = Map.of(
+            "sparse_field",
+            new InferenceFieldMetadata("sparse_field", sparseModel.getInferenceEntityId(), new String[] { "sparse_field" }, null)
+        );
+
+        BulkItemRequest[] items = new BulkItemRequest[3];
+        items[0] = new BulkItemRequest(0, new IndexRequest("index").id("doc_0").source("non_inference_field", "foo"));
+        items[1] = new BulkItemRequest(1, new IndexRequest("index").id("doc_1").source(doc1Source));
+        items[2] = new BulkItemRequest(2, new IndexRequest("index").id("doc_2").source("non_inference_field", "baz"));
+
+        BulkShardRequest request = new BulkShardRequest(new ShardId("test", "test", 0), WriteRequest.RefreshPolicy.NONE, items);
+        request.setInferenceFieldMap(inferenceFieldMap);
+        filter.apply(task, TransportShardBulkAction.ACTION_NAME, request, actionListener, actionFilterChain);
+        awaitLatch(chainExecuted, 10, TimeUnit.SECONDS);
+
+        IndexingPressure.Coordinating coordinatingIndexingPressure = indexingPressure.getCoordinating();
+        assertThat(coordinatingIndexingPressure, notNullValue());
+        verify(coordinatingIndexingPressure).close();
+    }
+
+    @SuppressWarnings("unchecked")
+    public void testIndexingPressureTripsOnInferenceResponseHandling() throws Exception {
+        final XContentBuilder doc1Source = IndexRequest.getXContentBuilder(XContentType.JSON, "sparse_field", "bar");
+        final InstrumentedIndexingPressure indexingPressure = new InstrumentedIndexingPressure(
+            Settings.builder().put(MAX_COORDINATING_BYTES.getKey(), (bytesUsed(doc1Source) + 1) + "b").build()
+        );
+
+        final StaticModel sparseModel = StaticModel.createRandomInstance(TaskType.SPARSE_EMBEDDING);
+        sparseModel.putResult("bar", randomChunkedInferenceEmbedding(sparseModel, List.of("bar")));
+
+        final ShardBulkInferenceActionFilter filter = createFilter(
+            threadPool,
+            Map.of(sparseModel.getInferenceEntityId(), sparseModel),
+            indexingPressure,
+            useLegacyFormat,
+            true
+        );
+
+        CountDownLatch chainExecuted = new CountDownLatch(1);
+        ActionFilterChain<BulkShardRequest, BulkShardResponse> actionFilterChain = (task, action, request, listener) -> {
+            try {
+                assertNull(request.getInferenceFieldMap());
+                assertThat(request.items().length, equalTo(3));
+
+                assertNull(request.items()[0].getPrimaryResponse());
+                assertNull(request.items()[2].getPrimaryResponse());
+
+                BulkItemRequest doc1Request = request.items()[1];
+                BulkItemResponse doc1Response = doc1Request.getPrimaryResponse();
+                assertNotNull(doc1Response);
+                assertTrue(doc1Response.isFailed());
+                BulkItemResponse.Failure doc1Failure = doc1Response.getFailure();
+                assertThat(
+                    doc1Failure.getCause().getMessage(),
+                    containsString("Insufficient memory available to insert inference results into document [doc_1]")
+                );
+                assertThat(doc1Failure.getCause().getCause(), instanceOf(EsRejectedExecutionException.class));
+                assertThat(doc1Failure.getStatus(), is(RestStatus.TOO_MANY_REQUESTS));
+
+                IndexRequest doc1IndexRequest = getIndexRequestOrNull(doc1Request.request());
+                assertThat(doc1IndexRequest, notNullValue());
+                assertThat(doc1IndexRequest.source(), equalTo(BytesReference.bytes(doc1Source)));
+
+                IndexingPressure.Coordinating coordinatingIndexingPressure = indexingPressure.getCoordinating();
+                assertThat(coordinatingIndexingPressure, notNullValue());
+                verify(coordinatingIndexingPressure).increment(1, bytesUsed(doc1Source));
+                verify(coordinatingIndexingPressure).increment(eq(0), longThat(l -> l > 0));
+                verify(coordinatingIndexingPressure, times(2)).increment(anyInt(), anyLong());
+
+                // Verify that the coordinating indexing pressure is maintained through downstream action filters
+                verify(coordinatingIndexingPressure, never()).close();
+
+                // Call the listener once the request is successfully processed, like is done in the production code path
+                listener.onResponse(null);
+            } finally {
+                chainExecuted.countDown();
+            }
+        };
+        ActionListener<BulkShardResponse> actionListener = (ActionListener<BulkShardResponse>) mock(ActionListener.class);
+        Task task = mock(Task.class);
+
+        Map<String, InferenceFieldMetadata> inferenceFieldMap = Map.of(
+            "sparse_field",
+            new InferenceFieldMetadata("sparse_field", sparseModel.getInferenceEntityId(), new String[] { "sparse_field" }, null)
+        );
+
+        BulkItemRequest[] items = new BulkItemRequest[3];
+        items[0] = new BulkItemRequest(0, new IndexRequest("index").id("doc_0").source("non_inference_field", "foo"));
+        items[1] = new BulkItemRequest(1, new IndexRequest("index").id("doc_1").source(doc1Source));
+        items[2] = new BulkItemRequest(2, new IndexRequest("index").id("doc_2").source("non_inference_field", "baz"));
+
+        BulkShardRequest request = new BulkShardRequest(new ShardId("test", "test", 0), WriteRequest.RefreshPolicy.NONE, items);
+        request.setInferenceFieldMap(inferenceFieldMap);
+        filter.apply(task, TransportShardBulkAction.ACTION_NAME, request, actionListener, actionFilterChain);
+        awaitLatch(chainExecuted, 10, TimeUnit.SECONDS);
+
+        IndexingPressure.Coordinating coordinatingIndexingPressure = indexingPressure.getCoordinating();
+        assertThat(coordinatingIndexingPressure, notNullValue());
+        verify(coordinatingIndexingPressure).close();
+    }
+
+    @SuppressWarnings("unchecked")
+    public void testIndexingPressurePartialFailure() throws Exception {
+        // Use different length strings so that doc 1 and doc 2 sources are different sizes
+        final XContentBuilder doc1Source = IndexRequest.getXContentBuilder(XContentType.JSON, "sparse_field", "bar");
+        final XContentBuilder doc2Source = IndexRequest.getXContentBuilder(XContentType.JSON, "sparse_field", "bazzz");
+
+        final StaticModel sparseModel = StaticModel.createRandomInstance(TaskType.SPARSE_EMBEDDING);
+        final ChunkedInferenceEmbedding barEmbedding = randomChunkedInferenceEmbedding(sparseModel, List.of("bar"));
+        final ChunkedInferenceEmbedding bazzzEmbedding = randomChunkedInferenceEmbedding(sparseModel, List.of("bazzz"));
+        sparseModel.putResult("bar", barEmbedding);
+        sparseModel.putResult("bazzz", bazzzEmbedding);
+
+        CheckedBiFunction<List<String>, ChunkedInference, Long, IOException> estimateInferenceResultsBytes = (inputs, inference) -> {
+            SemanticTextField semanticTextField = semanticTextFieldFromChunkedInferenceResults(
+                useLegacyFormat,
+                "sparse_field",
+                sparseModel,
+                null,
+                inputs,
+                inference,
+                XContentType.JSON
+            );
+            XContentBuilder builder = XContentFactory.jsonBuilder();
+            semanticTextField.toXContent(builder, EMPTY_PARAMS);
+            return bytesUsed(builder);
+        };
+
+        final InstrumentedIndexingPressure indexingPressure = new InstrumentedIndexingPressure(
+            Settings.builder()
+                .put(
+                    MAX_COORDINATING_BYTES.getKey(),
+                    (bytesUsed(doc1Source) + bytesUsed(doc2Source) + estimateInferenceResultsBytes.apply(List.of("bar"), barEmbedding)
+                        + (estimateInferenceResultsBytes.apply(List.of("bazzz"), bazzzEmbedding) / 2)) + "b"
+                )
+                .build()
+        );
+
+        final ShardBulkInferenceActionFilter filter = createFilter(
+            threadPool,
+            Map.of(sparseModel.getInferenceEntityId(), sparseModel),
+            indexingPressure,
+            useLegacyFormat,
+            true
+        );
+
+        CountDownLatch chainExecuted = new CountDownLatch(1);
+        ActionFilterChain<BulkShardRequest, BulkShardResponse> actionFilterChain = (task, action, request, listener) -> {
+            try {
+                assertNull(request.getInferenceFieldMap());
+                assertThat(request.items().length, equalTo(4));
+
+                assertNull(request.items()[0].getPrimaryResponse());
+                assertNull(request.items()[1].getPrimaryResponse());
+                assertNull(request.items()[3].getPrimaryResponse());
+
+                BulkItemRequest doc2Request = request.items()[2];
+                BulkItemResponse doc2Response = doc2Request.getPrimaryResponse();
+                assertNotNull(doc2Response);
+                assertTrue(doc2Response.isFailed());
+                BulkItemResponse.Failure doc2Failure = doc2Response.getFailure();
+                assertThat(
+                    doc2Failure.getCause().getMessage(),
+                    containsString("Insufficient memory available to insert inference results into document [doc_2]")
+                );
+                assertThat(doc2Failure.getCause().getCause(), instanceOf(EsRejectedExecutionException.class));
+                assertThat(doc2Failure.getStatus(), is(RestStatus.TOO_MANY_REQUESTS));
+
+                IndexRequest doc2IndexRequest = getIndexRequestOrNull(doc2Request.request());
+                assertThat(doc2IndexRequest, notNullValue());
+                assertThat(doc2IndexRequest.source(), equalTo(BytesReference.bytes(doc2Source)));
+
+                IndexingPressure.Coordinating coordinatingIndexingPressure = indexingPressure.getCoordinating();
+                assertThat(coordinatingIndexingPressure, notNullValue());
+                verify(coordinatingIndexingPressure).increment(1, bytesUsed(doc1Source));
+                verify(coordinatingIndexingPressure).increment(1, bytesUsed(doc2Source));
+                verify(coordinatingIndexingPressure, times(2)).increment(eq(0), longThat(l -> l > 0));
+                verify(coordinatingIndexingPressure, times(4)).increment(anyInt(), anyLong());
+
+                // Verify that the coordinating indexing pressure is maintained through downstream action filters
+                verify(coordinatingIndexingPressure, never()).close();
+
+                // Call the listener once the request is successfully processed, like is done in the production code path
+                listener.onResponse(null);
+            } finally {
+                chainExecuted.countDown();
+            }
+        };
+        ActionListener<BulkShardResponse> actionListener = (ActionListener<BulkShardResponse>) mock(ActionListener.class);
+        Task task = mock(Task.class);
+
+        Map<String, InferenceFieldMetadata> inferenceFieldMap = Map.of(
+            "sparse_field",
+            new InferenceFieldMetadata("sparse_field", sparseModel.getInferenceEntityId(), new String[] { "sparse_field" }, null)
+        );
+
+        BulkItemRequest[] items = new BulkItemRequest[4];
+        items[0] = new BulkItemRequest(0, new IndexRequest("index").id("doc_0").source("non_inference_field", "foo"));
+        items[1] = new BulkItemRequest(1, new IndexRequest("index").id("doc_1").source(doc1Source));
+        items[2] = new BulkItemRequest(2, new IndexRequest("index").id("doc_2").source(doc2Source));
+        items[3] = new BulkItemRequest(3, new IndexRequest("index").id("doc_3").source("non_inference_field", "baz"));
+
+        BulkShardRequest request = new BulkShardRequest(new ShardId("test", "test", 0), WriteRequest.RefreshPolicy.NONE, items);
+        request.setInferenceFieldMap(inferenceFieldMap);
+        filter.apply(task, TransportShardBulkAction.ACTION_NAME, request, actionListener, actionFilterChain);
+        awaitLatch(chainExecuted, 10, TimeUnit.SECONDS);
+
+        IndexingPressure.Coordinating coordinatingIndexingPressure = indexingPressure.getCoordinating();
+        assertThat(coordinatingIndexingPressure, notNullValue());
+        verify(coordinatingIndexingPressure).close();
+    }
+
     @SuppressWarnings("unchecked")
     private static ShardBulkInferenceActionFilter createFilter(
         ThreadPool threadPool,
         Map<String, StaticModel> modelMap,
+        IndexingPressure indexingPressure,
         boolean useLegacyFormat,
         boolean isLicenseValidForInference
     ) {
@@ -506,6 +917,17 @@ private static ShardBulkInferenceActionFilter createFilter(
         };
         doAnswer(unparsedModelAnswer).when(modelRegistry).getModelWithSecrets(any(), any());
 
+        Answer<MinimalServiceSettings> minimalServiceSettingsAnswer = invocationOnMock -> {
+            String inferenceId = (String) invocationOnMock.getArguments()[0];
+            var model = modelMap.get(inferenceId);
+            if (model == null) {
+                throw new ResourceNotFoundException("model id [{}] not found", inferenceId);
+            }
+
+            return new MinimalServiceSettings(model);
+        };
+        doAnswer(minimalServiceSettingsAnswer).when(modelRegistry).getMinimalServiceSettings(any());
+
         InferenceService inferenceService = mock(InferenceService.class);
         Answer<?> chunkedInferAnswer = invocationOnMock -> {
             StaticModel model = (StaticModel) invocationOnMock.getArguments()[0];
@@ -547,7 +969,8 @@ private static ShardBulkInferenceActionFilter createFilter(
             createClusterService(useLegacyFormat),
             inferenceServiceRegistry,
             modelRegistry,
-            licenseState
+            licenseState,
+            indexingPressure
         );
     }
 
@@ -635,6 +1058,10 @@ private static BulkItemRequest[] randomBulkItemRequest(
             new BulkItemRequest(requestId, new IndexRequest("index").source(expectedDocMap, requestContentType)) };
     }
 
+    private static long bytesUsed(XContentBuilder builder) {
+        return BytesReference.bytes(builder).ramBytesUsed();
+    }
+
     @SuppressWarnings({ "unchecked" })
     private static void assertInferenceResults(
         boolean useLegacyFormat,
@@ -699,7 +1126,11 @@ private static class StaticModel extends TestModel {
         }
 
         public static StaticModel createRandomInstance() {
-            TestModel testModel = TestModel.createRandomInstance();
+            return createRandomInstance(randomFrom(TaskType.TEXT_EMBEDDING, TaskType.SPARSE_EMBEDDING));
+        }
+
+        public static StaticModel createRandomInstance(TaskType taskType) {
+            TestModel testModel = TestModel.createRandomInstance(taskType);
             return new StaticModel(
                 testModel.getInferenceEntityId(),
                 testModel.getTaskType(),
@@ -722,4 +1153,42 @@ boolean hasResult(String text) {
             return resultMap.containsKey(text);
         }
     }
+
+    private static class InstrumentedIndexingPressure extends IndexingPressure {
+        private Coordinating coordinating = null;
+
+        private InstrumentedIndexingPressure(Settings settings) {
+            super(settings);
+        }
+
+        private Coordinating getCoordinating() {
+            return coordinating;
+        }
+
+        @Override
+        public Coordinating createCoordinatingOperation(boolean forceExecution) {
+            coordinating = spy(super.createCoordinatingOperation(forceExecution));
+            return coordinating;
+        }
+    }
+
+    private static class NoopIndexingPressure extends IndexingPressure {
+        private NoopIndexingPressure() {
+            super(Settings.EMPTY);
+        }
+
+        @Override
+        public Coordinating createCoordinatingOperation(boolean forceExecution) {
+            return new NoopCoordinating(forceExecution);
+        }
+
+        private class NoopCoordinating extends Coordinating {
+            private NoopCoordinating(boolean forceExecution) {
+                super(forceExecution);
+            }
+
+            @Override
+            public void increment(int operations, long bytes) {}
+        }
+    }
 }
diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/mapper/SemanticTextFieldTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/mapper/SemanticTextFieldTests.java
index b4ac5c475d425..19ae099bbb27e 100644
--- a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/mapper/SemanticTextFieldTests.java
+++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/mapper/SemanticTextFieldTests.java
@@ -185,6 +185,17 @@ public void testModelSettingsValidation() {
         assertThat(ex.getMessage(), containsString("required [element_type] field is missing"));
     }
 
+    public static ChunkedInferenceEmbedding randomChunkedInferenceEmbedding(Model model, List<String> inputs) {
+        return switch (model.getTaskType()) {
+            case SPARSE_EMBEDDING -> randomChunkedInferenceEmbeddingSparse(inputs);
+            case TEXT_EMBEDDING -> switch (model.getServiceSettings().elementType()) {
+                case FLOAT -> randomChunkedInferenceEmbeddingFloat(model, inputs);
+                case BIT, BYTE -> randomChunkedInferenceEmbeddingByte(model, inputs);
+            };
+            default -> throw new AssertionError("invalid task type: " + model.getTaskType().name());
+        };
+    }
+
     public static ChunkedInferenceEmbedding randomChunkedInferenceEmbeddingByte(Model model, List<String> inputs) {
         DenseVectorFieldMapper.ElementType elementType = model.getServiceSettings().elementType();
         int embeddingLength = DenseVectorFieldMapperTestUtils.getEmbeddingLength(elementType, model.getServiceSettings().dimensions());


Reviews:
user: login: Mikep86
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T19:40:36Z
user: login: jimczi
body: We don't need to add a new circuit breaker here. The bulk action already includes an [`IndexingPressure`](https://github.com/elastic/elasticsearch/blob/3b685cb27a73f39ad491a3563bff52e93d572f0f/server/src/main/java/org/elasticsearch/index/IndexingPressure.java#L26) object, which looks promising. There's even a [TODO](https://github.com/elastic/elasticsearch/blob/3b685cb27a73f39ad491a3563bff52e93d572f0f/server/src/main/java/org/elasticsearch/index/IndexingPressure.java#L244) to use it for ingest pipelines, so I believe we should follow that approach. 
Can @elastic/es-distributed-indexing provide some guidance on this?




state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T09:34:15Z
user: login: jan-elastic
body: I've left some comments. I feel like I'm misunderstanding something...
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T12:55:10Z
user: login: Mikep86
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T18:02:51Z
user: login: Mikep86
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T18:05:45Z
user: login: Mikep86
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T18:22:20Z
user: login: Mikep86
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T18:27:17Z
user: login: Mikep86
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T18:27:52Z
user: login: Mikep86
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T18:30:52Z
user: login: Mikep86
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T18:42:33Z
user: login: Mikep86
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T18:55:57Z
user: login: Mikep86
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T13:39:51Z
user: login: jimczi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T21:55:13Z
user: login: Mikep86
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T19:43:14Z
user: login: jimczi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T20:50:03Z
user: login: Mikep86
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T21:03:37Z
user: login: jimczi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T00:49:13Z
user: login: Mikep86
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T12:08:23Z
user: login: Mikep86
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T21:18:34Z
user: login: jimczi
body: The logic looks good to me, @Tim-Brooks can you help reviewing the usage of the `IndexingPressure`?
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T09:06:34Z
user: login: ldematte
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T12:10:24Z
user: login: kderusso
body: Changes look reasonable to me, but will let people more familiar with indexing pressure approve
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T12:36:48Z
user: login: Mikep86
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T12:58:59Z
user: login: Mikep86
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T13:00:55Z
user: login: ldematte
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T13:15:00Z
user: login: Mikep86
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T13:23:06Z
user: login: jan-elastic
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T07:23:54Z
user: login: jan-elastic
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T07:34:10Z
user: login: davidkyle
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T10:54:48Z
user: login: jan-elastic
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T11:02:20Z
user: login: Mikep86
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T12:21:08Z
user: login: Mikep86
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T12:24:24Z
user: login: Mikep86
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T12:25:32Z
user: login: kderusso
body: LGTM, thanks for iterating
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-09T17:54:10Z
user: login: davidkyle
body: LGTM

Thanks @Mikep86 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-10T10:02:04Z
user: login: jimczi
body: Thanks, @Mikep86.
Let’s update the PR title and summary to reflect the use of indexing pressure.
It would be great if someone from @elastic/es-distributed-indexing could review this section.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T07:50:11Z

Review comments:
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/InferenceException.java
user: login: Mikep86
body: This exception type was introduced in https://github.com/elastic/elasticsearch/pull/123890. I found an issue where `ElasticsearchWrapperException` messages are discarded when the exception is emitted to XContent. This is not the intent of this exception type. The intent is to pass through the REST status of the cause while maintaining the message attached to the `InferenceException`.

I modified the exception implementation to match the intent, but we should separate this change from the PR so it can be appropriately backported.

cc: @tteofili 
created_at: 2025-03-24T19:40:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -238,14 +251,151 @@ private AsyncBulkShardInferenceAction(
             this.fieldInferenceMap = fieldInferenceMap;
             this.bulkShardRequest = bulkShardRequest;
             this.inferenceResults = new AtomicArray<>(bulkShardRequest.items().length);
-            this.onCompletion = onCompletion;
+            this.onCompletion = () -> {
+                // TODO: Can we assume that onCompletion _always_ runs, regardless of what errors occur?
+                // Won't run if estimateMemoryUsage throws, which is the only scenario where it's ok to not run it.
+                CircuitBreaker circuitBreaker = inferenceBytesCircuitBreaker.get();
+                if (circuitBreaker != null && actualMemoryUsageInBytes > 0) {
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: jan-elastic
body: is it possible that `circuitBreaker == null`?
created_at: 2025-03-25T11:31:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -238,14 +251,151 @@ private AsyncBulkShardInferenceAction(
             this.fieldInferenceMap = fieldInferenceMap;
             this.bulkShardRequest = bulkShardRequest;
             this.inferenceResults = new AtomicArray<>(bulkShardRequest.items().length);
-            this.onCompletion = onCompletion;
+            this.onCompletion = () -> {
+                // TODO: Can we assume that onCompletion _always_ runs, regardless of what errors occur?
+                // Won't run if estimateMemoryUsage throws, which is the only scenario where it's ok to not run it.
+                CircuitBreaker circuitBreaker = inferenceBytesCircuitBreaker.get();
+                if (circuitBreaker != null && actualMemoryUsageInBytes > 0) {
+                    circuitBreaker.addWithoutBreaking(-actualMemoryUsageInBytes);
+                }
+
+                onCompletion.run();
+            };
         }
 
         @Override
         public void run() {
+            estimateMemoryUsage();
             executeNext(0);
         }
 
+        private void estimateMemoryUsage() {
+            CircuitBreaker circuitBreaker = inferenceBytesCircuitBreaker.get();
+            if (circuitBreaker == null) {
+                return;
+            }
+
+            for (BulkItemRequest item : bulkShardRequest.items()) {
+                boolean isUpdateRequest = false;
+                final IndexRequest indexRequest;
+                if (item.request() instanceof IndexRequest ir) {
+                    indexRequest = ir;
+                } else if (item.request() instanceof UpdateRequest updateRequest) {
+                    isUpdateRequest = true;
+                    if (updateRequest.script() != null) {
+                        // Skip script updates, we will error out on those later
+                        continue;
+                    }
+
+                    indexRequest = updateRequest.doc();
+                } else {
+                    // Ignore delete requests
+                    continue;
+                }
+
+                // TODO: Don't know how to avoid getting the doc map here, we need it to determine if the doc has any inference fields in it
+                // and if so, what they are
+                long estimatedEmbeddingBytes = 0;
+                boolean noInferenceSourceUpdate = false;
+                final Map<String, Object> docMap = indexRequest.sourceAsMap();
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: jan-elastic
body: If you keep it like this, you could add `source.length()` to the circuit breaker and subtract it when this map goes out of scope.
created_at: 2025-03-25T11:38:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -238,14 +251,151 @@ private AsyncBulkShardInferenceAction(
             this.fieldInferenceMap = fieldInferenceMap;
             this.bulkShardRequest = bulkShardRequest;
             this.inferenceResults = new AtomicArray<>(bulkShardRequest.items().length);
-            this.onCompletion = onCompletion;
+            this.onCompletion = () -> {
+                // TODO: Can we assume that onCompletion _always_ runs, regardless of what errors occur?
+                // Won't run if estimateMemoryUsage throws, which is the only scenario where it's ok to not run it.
+                CircuitBreaker circuitBreaker = inferenceBytesCircuitBreaker.get();
+                if (circuitBreaker != null && actualMemoryUsageInBytes > 0) {
+                    circuitBreaker.addWithoutBreaking(-actualMemoryUsageInBytes);
+                }
+
+                onCompletion.run();
+            };
         }
 
         @Override
         public void run() {
+            estimateMemoryUsage();
             executeNext(0);
         }
 
+        private void estimateMemoryUsage() {
+            CircuitBreaker circuitBreaker = inferenceBytesCircuitBreaker.get();
+            if (circuitBreaker == null) {
+                return;
+            }
+
+            for (BulkItemRequest item : bulkShardRequest.items()) {
+                boolean isUpdateRequest = false;
+                final IndexRequest indexRequest;
+                if (item.request() instanceof IndexRequest ir) {
+                    indexRequest = ir;
+                } else if (item.request() instanceof UpdateRequest updateRequest) {
+                    isUpdateRequest = true;
+                    if (updateRequest.script() != null) {
+                        // Skip script updates, we will error out on those later
+                        continue;
+                    }
+
+                    indexRequest = updateRequest.doc();
+                } else {
+                    // Ignore delete requests
+                    continue;
+                }
+
+                // TODO: Don't know how to avoid getting the doc map here, we need it to determine if the doc has any inference fields in it
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: jan-elastic
body: I'd say these multiple long nested for loops could use some refactoring. I've scrolled up and down a few times to see what code is on what level.

How about starting with:
```
for (BulkItemRequest item : bulkShardRequest.items()) {
    (...)
    estimateMemoryUsage(indexRequest);
}
created_at: 2025-03-25T12:18:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -238,14 +251,151 @@ private AsyncBulkShardInferenceAction(
             this.fieldInferenceMap = fieldInferenceMap;
             this.bulkShardRequest = bulkShardRequest;
             this.inferenceResults = new AtomicArray<>(bulkShardRequest.items().length);
-            this.onCompletion = onCompletion;
+            this.onCompletion = () -> {
+                // TODO: Can we assume that onCompletion _always_ runs, regardless of what errors occur?
+                // Won't run if estimateMemoryUsage throws, which is the only scenario where it's ok to not run it.
+                CircuitBreaker circuitBreaker = inferenceBytesCircuitBreaker.get();
+                if (circuitBreaker != null && actualMemoryUsageInBytes > 0) {
+                    circuitBreaker.addWithoutBreaking(-actualMemoryUsageInBytes);
+                }
+
+                onCompletion.run();
+            };
         }
 
         @Override
         public void run() {
+            estimateMemoryUsage();
             executeNext(0);
         }
 
+        private void estimateMemoryUsage() {
+            CircuitBreaker circuitBreaker = inferenceBytesCircuitBreaker.get();
+            if (circuitBreaker == null) {
+                return;
+            }
+
+            for (BulkItemRequest item : bulkShardRequest.items()) {
+                boolean isUpdateRequest = false;
+                final IndexRequest indexRequest;
+                if (item.request() instanceof IndexRequest ir) {
+                    indexRequest = ir;
+                } else if (item.request() instanceof UpdateRequest updateRequest) {
+                    isUpdateRequest = true;
+                    if (updateRequest.script() != null) {
+                        // Skip script updates, we will error out on those later
+                        continue;
+                    }
+
+                    indexRequest = updateRequest.doc();
+                } else {
+                    // Ignore delete requests
+                    continue;
+                }
+
+                // TODO: Don't know how to avoid getting the doc map here, we need it to determine if the doc has any inference fields in it
+                // and if so, what they are
+                long estimatedEmbeddingBytes = 0;
+                boolean noInferenceSourceUpdate = false;
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: jan-elastic
body: I don't understand the logic of `noInferenceSourceUpdate`. Is that a property of a request, or per inferenceFieldMetadata, or per sourceField? Could you add some comments on it?

Should this `noInferenceSourceUpdate` be inside the `InferenceFieldMetadata` for loop?


created_at: 2025-03-25T12:27:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -238,14 +251,151 @@ private AsyncBulkShardInferenceAction(
             this.fieldInferenceMap = fieldInferenceMap;
             this.bulkShardRequest = bulkShardRequest;
             this.inferenceResults = new AtomicArray<>(bulkShardRequest.items().length);
-            this.onCompletion = onCompletion;
+            this.onCompletion = () -> {
+                // TODO: Can we assume that onCompletion _always_ runs, regardless of what errors occur?
+                // Won't run if estimateMemoryUsage throws, which is the only scenario where it's ok to not run it.
+                CircuitBreaker circuitBreaker = inferenceBytesCircuitBreaker.get();
+                if (circuitBreaker != null && actualMemoryUsageInBytes > 0) {
+                    circuitBreaker.addWithoutBreaking(-actualMemoryUsageInBytes);
+                }
+
+                onCompletion.run();
+            };
         }
 
         @Override
         public void run() {
+            estimateMemoryUsage();
             executeNext(0);
         }
 
+        private void estimateMemoryUsage() {
+            CircuitBreaker circuitBreaker = inferenceBytesCircuitBreaker.get();
+            if (circuitBreaker == null) {
+                return;
+            }
+
+            for (BulkItemRequest item : bulkShardRequest.items()) {
+                boolean isUpdateRequest = false;
+                final IndexRequest indexRequest;
+                if (item.request() instanceof IndexRequest ir) {
+                    indexRequest = ir;
+                } else if (item.request() instanceof UpdateRequest updateRequest) {
+                    isUpdateRequest = true;
+                    if (updateRequest.script() != null) {
+                        // Skip script updates, we will error out on those later
+                        continue;
+                    }
+
+                    indexRequest = updateRequest.doc();
+                } else {
+                    // Ignore delete requests
+                    continue;
+                }
+
+                // TODO: Don't know how to avoid getting the doc map here, we need it to determine if the doc has any inference fields in it
+                // and if so, what they are
+                long estimatedEmbeddingBytes = 0;
+                boolean noInferenceSourceUpdate = false;
+                final Map<String, Object> docMap = indexRequest.sourceAsMap();
+                for (InferenceFieldMetadata inferenceFieldMetadata : fieldInferenceMap.values()) {
+                    String inferenceId = inferenceFieldMetadata.getInferenceId();
+                    MinimalServiceSettings minimalServiceSettings = null;
+
+                    if (isInferenceRequired(inferenceFieldMetadata, docMap) == false) {
+                        continue;
+                    }
+
+                    for (String sourceField : inferenceFieldMetadata.getSourceFields()) {
+                        var valueObj = XContentMapValues.extractValue(sourceField, docMap, EXPLICIT_NULL);
+                        if (useLegacyFormat == false && isUpdateRequest && valueObj == EXPLICIT_NULL) {
+                            // This is an update request that will clear inference results, which means that a copy of the request source
+                            // will be generated
+                            noInferenceSourceUpdate = true;
+                            continue;
+                        } else if (valueObj == null || valueObj == EXPLICIT_NULL) {
+                            continue;
+                        }
+
+                        final List<String> values;
+                        try {
+                            values = SemanticTextUtils.nodeStringValues(sourceField, valueObj);
+                        } catch (Exception exc) {
+                            // Skip this source field for now, the invalid value will be surfaced during field inference request generation
+                            continue;
+                        }
+
+                        for (String v : values) {
+                            if (v.isBlank()) {
+                                // We update source to insert an empty chunk list on blank input
+                                noInferenceSourceUpdate = true;
+                            } else {
+                                if (minimalServiceSettings == null) {
+                                    minimalServiceSettings = getMinimalServiceSettings(inferenceId, inferenceFieldMetadata.getName());
+                                }
+
+                                // TODO: Estimate chunk count based on string length
+                                estimatedEmbeddingBytes += switch (minimalServiceSettings.taskType()) {
+                                    case SPARSE_EMBEDDING -> 128; // TODO: Estimate sparse embedding size
+                                    case TEXT_EMBEDDING -> minimalServiceSettings.elementType()
+                                        .getNumBytes(minimalServiceSettings.dimensions());
+                                    default -> throw new IllegalStateException(
+                                        "Inference ID ["
+                                            + inferenceId
+                                            + "] uses unsupported task type ["
+                                            + minimalServiceSettings.taskType()
+                                            + "]"
+                                    );
+                                };
+                            }
+                        }
+                    }
+                }
+
+                // TODO: Verify that ramBytesUsed is what to use here
+                long estimatedMemoryUsage = 0;
+                long newEstimatedInferenceRequestMemoryUsageInBytes = estimatedInferenceRequestMemoryUsageInBytes;
+                long newNoInferenceRequestMemoryUsageInBytes = noInferenceRequestMemoryUsageInBytes;
+                if (estimatedEmbeddingBytes > 0) {
+                    estimatedMemoryUsage = estimatedEmbeddingBytes + indexRequest.source().ramBytesUsed();
+                    newEstimatedInferenceRequestMemoryUsageInBytes += estimatedMemoryUsage;
+                } else if (noInferenceSourceUpdate) {
+                    estimatedMemoryUsage = indexRequest.source().ramBytesUsed();
+                    newNoInferenceRequestMemoryUsageInBytes += estimatedMemoryUsage;
+                }
+
+                if (estimatedMemoryUsage > 0) {
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: jan-elastic
body: If I understand correctly, `estimatedMemoryUsage > 0` is always true. It's at least the source size.
created_at: 2025-03-25T12:30:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -228,6 +237,10 @@ private class AsyncBulkShardInferenceAction implements Runnable {
         private final Runnable onCompletion;
         private final AtomicArray<FieldInferenceResponseAccumulator> inferenceResults;
 
+        private long noInferenceRequestMemoryUsageInBytes = 0;  // Memory used by requests that update source but don't perform inference
+        private long estimatedInferenceRequestMemoryUsageInBytes = 0;  // Estimated memory used by requests that perform inference
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: jan-elastic
body: What's the purpose of tracking these separately?
created_at: 2025-03-25T12:37:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -625,6 +791,57 @@ private void applyInferenceResponses(BulkItemRequest item, FieldInferenceRespons
                 }
             }
         }
+
+        private boolean updateActualMemoryUsage(
+            FieldInferenceRequest request,
+            ChunkedInference chunkedInference,
+            FieldInferenceResponseAccumulator accumulator
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: jan-elastic
body: I think it's cleaner to not pass the accumulator here and just return false.

Where you call it, do something like:
```
if (updateActualMemoryUsage(...))
  acc.addOrUpdateResponse(...)
else
  acc.addFailure(..)
created_at: 2025-03-25T12:43:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -625,6 +791,57 @@ private void applyInferenceResponses(BulkItemRequest item, FieldInferenceRespons
                 }
             }
         }
+
+        private boolean updateActualMemoryUsage(
+            FieldInferenceRequest request,
+            ChunkedInference chunkedInference,
+            FieldInferenceResponseAccumulator accumulator
+        ) {
+            boolean success = true;
+
+            CircuitBreaker circuitBreaker = inferenceBytesCircuitBreaker.get();
+            if (circuitBreaker == null) {
+                return success;
+            }
+
+            try {
+                final BulkItemRequest item = bulkShardRequest.items()[request.bulkItemIndex()];
+                final IndexRequest indexRequest = getIndexRequestOrNull(item.request());
+
+                long chunkBytes = 0;
+                Iterator<ChunkedInference.Chunk> chunkIterator = chunkedInference.chunksAsByteReference(
+                    indexRequest.getContentType().xContent()
+                );
+                while (chunkIterator.hasNext()) {
+                    ChunkedInference.Chunk chunk = chunkIterator.next();
+                    chunkBytes += chunk.bytesReference().ramBytesUsed();
+                }
+
+                // This doesn't count the bytes used for the inference metadata field structure (in the new format) or the field structure
+                // around the embeddings (in the legacy format), but it's a close approximation.
+                long bytesUsed = chunkBytes + indexRequest.source().ramBytesUsed();
+                if (bytesUsed <= estimatedInferenceRequestMemoryUsageInBytes) {
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: jan-elastic
body: I'm probably not understanding something correctly.

But in my understanding `estimatedInferenceRequestMemoryUsageInBytes` is summed over all inference requests, while `bytesUsed` is just for a single request. I think you should keep track of the estimated bytes per request, and update the breaker accordingly.
created_at: 2025-03-25T12:50:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -238,14 +251,151 @@ private AsyncBulkShardInferenceAction(
             this.fieldInferenceMap = fieldInferenceMap;
             this.bulkShardRequest = bulkShardRequest;
             this.inferenceResults = new AtomicArray<>(bulkShardRequest.items().length);
-            this.onCompletion = onCompletion;
+            this.onCompletion = () -> {
+                // TODO: Can we assume that onCompletion _always_ runs, regardless of what errors occur?
+                // Won't run if estimateMemoryUsage throws, which is the only scenario where it's ok to not run it.
+                CircuitBreaker circuitBreaker = inferenceBytesCircuitBreaker.get();
+                if (circuitBreaker != null && actualMemoryUsageInBytes > 0) {
+                    circuitBreaker.addWithoutBreaking(-actualMemoryUsageInBytes);
+                }
+
+                onCompletion.run();
+            };
         }
 
         @Override
         public void run() {
+            estimateMemoryUsage();
             executeNext(0);
         }
 
+        private void estimateMemoryUsage() {
+            CircuitBreaker circuitBreaker = inferenceBytesCircuitBreaker.get();
+            if (circuitBreaker == null) {
+                return;
+            }
+
+            for (BulkItemRequest item : bulkShardRequest.items()) {
+                boolean isUpdateRequest = false;
+                final IndexRequest indexRequest;
+                if (item.request() instanceof IndexRequest ir) {
+                    indexRequest = ir;
+                } else if (item.request() instanceof UpdateRequest updateRequest) {
+                    isUpdateRequest = true;
+                    if (updateRequest.script() != null) {
+                        // Skip script updates, we will error out on those later
+                        continue;
+                    }
+
+                    indexRequest = updateRequest.doc();
+                } else {
+                    // Ignore delete requests
+                    continue;
+                }
+
+                // TODO: Don't know how to avoid getting the doc map here, we need it to determine if the doc has any inference fields in it
+                // and if so, what they are
+                long estimatedEmbeddingBytes = 0;
+                boolean noInferenceSourceUpdate = false;
+                final Map<String, Object> docMap = indexRequest.sourceAsMap();
+                for (InferenceFieldMetadata inferenceFieldMetadata : fieldInferenceMap.values()) {
+                    String inferenceId = inferenceFieldMetadata.getInferenceId();
+                    MinimalServiceSettings minimalServiceSettings = null;
+
+                    if (isInferenceRequired(inferenceFieldMetadata, docMap) == false) {
+                        continue;
+                    }
+
+                    for (String sourceField : inferenceFieldMetadata.getSourceFields()) {
+                        var valueObj = XContentMapValues.extractValue(sourceField, docMap, EXPLICIT_NULL);
+                        if (useLegacyFormat == false && isUpdateRequest && valueObj == EXPLICIT_NULL) {
+                            // This is an update request that will clear inference results, which means that a copy of the request source
+                            // will be generated
+                            noInferenceSourceUpdate = true;
+                            continue;
+                        } else if (valueObj == null || valueObj == EXPLICIT_NULL) {
+                            continue;
+                        }
+
+                        final List<String> values;
+                        try {
+                            values = SemanticTextUtils.nodeStringValues(sourceField, valueObj);
+                        } catch (Exception exc) {
+                            // Skip this source field for now, the invalid value will be surfaced during field inference request generation
+                            continue;
+                        }
+
+                        for (String v : values) {
+                            if (v.isBlank()) {
+                                // We update source to insert an empty chunk list on blank input
+                                noInferenceSourceUpdate = true;
+                            } else {
+                                if (minimalServiceSettings == null) {
+                                    minimalServiceSettings = getMinimalServiceSettings(inferenceId, inferenceFieldMetadata.getName());
+                                }
+
+                                // TODO: Estimate chunk count based on string length
+                                estimatedEmbeddingBytes += switch (minimalServiceSettings.taskType()) {
+                                    case SPARSE_EMBEDDING -> 128; // TODO: Estimate sparse embedding size
+                                    case TEXT_EMBEDDING -> minimalServiceSettings.elementType()
+                                        .getNumBytes(minimalServiceSettings.dimensions());
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: jan-elastic
body: Not sure this is a good estimate. I think the embedding is stored as a JSON string, which takes quite some more space than a float.
created_at: 2025-03-25T12:53:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -238,14 +251,151 @@ private AsyncBulkShardInferenceAction(
             this.fieldInferenceMap = fieldInferenceMap;
             this.bulkShardRequest = bulkShardRequest;
             this.inferenceResults = new AtomicArray<>(bulkShardRequest.items().length);
-            this.onCompletion = onCompletion;
+            this.onCompletion = () -> {
+                // TODO: Can we assume that onCompletion _always_ runs, regardless of what errors occur?
+                // Won't run if estimateMemoryUsage throws, which is the only scenario where it's ok to not run it.
+                CircuitBreaker circuitBreaker = inferenceBytesCircuitBreaker.get();
+                if (circuitBreaker != null && actualMemoryUsageInBytes > 0) {
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: Mikep86
body: Yes, we don't set the circuit breaker on some unit/integration test code paths to keep the tests focused
created_at: 2025-03-28T18:02:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -238,14 +251,151 @@ private AsyncBulkShardInferenceAction(
             this.fieldInferenceMap = fieldInferenceMap;
             this.bulkShardRequest = bulkShardRequest;
             this.inferenceResults = new AtomicArray<>(bulkShardRequest.items().length);
-            this.onCompletion = onCompletion;
+            this.onCompletion = () -> {
+                // TODO: Can we assume that onCompletion _always_ runs, regardless of what errors occur?
+                // Won't run if estimateMemoryUsage throws, which is the only scenario where it's ok to not run it.
+                CircuitBreaker circuitBreaker = inferenceBytesCircuitBreaker.get();
+                if (circuitBreaker != null && actualMemoryUsageInBytes > 0) {
+                    circuitBreaker.addWithoutBreaking(-actualMemoryUsageInBytes);
+                }
+
+                onCompletion.run();
+            };
         }
 
         @Override
         public void run() {
+            estimateMemoryUsage();
             executeNext(0);
         }
 
+        private void estimateMemoryUsage() {
+            CircuitBreaker circuitBreaker = inferenceBytesCircuitBreaker.get();
+            if (circuitBreaker == null) {
+                return;
+            }
+
+            for (BulkItemRequest item : bulkShardRequest.items()) {
+                boolean isUpdateRequest = false;
+                final IndexRequest indexRequest;
+                if (item.request() instanceof IndexRequest ir) {
+                    indexRequest = ir;
+                } else if (item.request() instanceof UpdateRequest updateRequest) {
+                    isUpdateRequest = true;
+                    if (updateRequest.script() != null) {
+                        // Skip script updates, we will error out on those later
+                        continue;
+                    }
+
+                    indexRequest = updateRequest.doc();
+                } else {
+                    // Ignore delete requests
+                    continue;
+                }
+
+                // TODO: Don't know how to avoid getting the doc map here, we need it to determine if the doc has any inference fields in it
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: Mikep86
body: Sure, i'll try to refactor these loops to make them more readable
created_at: 2025-03-28T18:05:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -228,6 +237,10 @@ private class AsyncBulkShardInferenceAction implements Runnable {
         private final Runnable onCompletion;
         private final AtomicArray<FieldInferenceResponseAccumulator> inferenceResults;
 
+        private long noInferenceRequestMemoryUsageInBytes = 0;  // Memory used by requests that update source but don't perform inference
+        private long estimatedInferenceRequestMemoryUsageInBytes = 0;  // Estimated memory used by requests that perform inference
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: Mikep86
body: There are two types of requests we modify source for: 

- Requests we perform inference on and inject real embeddings into, tracked by `estimatedInferenceRequestMemoryUsageInBytes`.
- Requests we _don't_ perform inference on and inject empty embeddings into. We need to do this in some cases to either create the necessary subfield structure or clear existing embeddings from the index. These are tracked by `noInferenceRequestMemoryUsageInBytes`.

As we perform inference, we discount the actual amount of memory we use against `estimatedInferenceRequestMemoryUsageInBytes`. If we exceed the estimated memory usage for this request type, we allocate the excess memory with the circuit breaker.

The latter request type will not be processed by `updateActualMemoryUsage`, since we don't perform inference. We track it separately so that:

- The memory used for such requests is accounted for
- We can accurately know when we've exhausted our memory estimate for requests that perform inference
created_at: 2025-03-28T18:22:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -238,14 +251,151 @@ private AsyncBulkShardInferenceAction(
             this.fieldInferenceMap = fieldInferenceMap;
             this.bulkShardRequest = bulkShardRequest;
             this.inferenceResults = new AtomicArray<>(bulkShardRequest.items().length);
-            this.onCompletion = onCompletion;
+            this.onCompletion = () -> {
+                // TODO: Can we assume that onCompletion _always_ runs, regardless of what errors occur?
+                // Won't run if estimateMemoryUsage throws, which is the only scenario where it's ok to not run it.
+                CircuitBreaker circuitBreaker = inferenceBytesCircuitBreaker.get();
+                if (circuitBreaker != null && actualMemoryUsageInBytes > 0) {
+                    circuitBreaker.addWithoutBreaking(-actualMemoryUsageInBytes);
+                }
+
+                onCompletion.run();
+            };
         }
 
         @Override
         public void run() {
+            estimateMemoryUsage();
             executeNext(0);
         }
 
+        private void estimateMemoryUsage() {
+            CircuitBreaker circuitBreaker = inferenceBytesCircuitBreaker.get();
+            if (circuitBreaker == null) {
+                return;
+            }
+
+            for (BulkItemRequest item : bulkShardRequest.items()) {
+                boolean isUpdateRequest = false;
+                final IndexRequest indexRequest;
+                if (item.request() instanceof IndexRequest ir) {
+                    indexRequest = ir;
+                } else if (item.request() instanceof UpdateRequest updateRequest) {
+                    isUpdateRequest = true;
+                    if (updateRequest.script() != null) {
+                        // Skip script updates, we will error out on those later
+                        continue;
+                    }
+
+                    indexRequest = updateRequest.doc();
+                } else {
+                    // Ignore delete requests
+                    continue;
+                }
+
+                // TODO: Don't know how to avoid getting the doc map here, we need it to determine if the doc has any inference fields in it
+                // and if so, what they are
+                long estimatedEmbeddingBytes = 0;
+                boolean noInferenceSourceUpdate = false;
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: Mikep86
body: This is a property of the request. It's used to detect requests that require no-inference source updates (see [my comment above](https://github.com/elastic/elasticsearch/pull/125517/files#r2019138268)). I can add a comment to that effect.
created_at: 2025-03-28T18:27:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -238,14 +251,151 @@ private AsyncBulkShardInferenceAction(
             this.fieldInferenceMap = fieldInferenceMap;
             this.bulkShardRequest = bulkShardRequest;
             this.inferenceResults = new AtomicArray<>(bulkShardRequest.items().length);
-            this.onCompletion = onCompletion;
+            this.onCompletion = () -> {
+                // TODO: Can we assume that onCompletion _always_ runs, regardless of what errors occur?
+                // Won't run if estimateMemoryUsage throws, which is the only scenario where it's ok to not run it.
+                CircuitBreaker circuitBreaker = inferenceBytesCircuitBreaker.get();
+                if (circuitBreaker != null && actualMemoryUsageInBytes > 0) {
+                    circuitBreaker.addWithoutBreaking(-actualMemoryUsageInBytes);
+                }
+
+                onCompletion.run();
+            };
         }
 
         @Override
         public void run() {
+            estimateMemoryUsage();
             executeNext(0);
         }
 
+        private void estimateMemoryUsage() {
+            CircuitBreaker circuitBreaker = inferenceBytesCircuitBreaker.get();
+            if (circuitBreaker == null) {
+                return;
+            }
+
+            for (BulkItemRequest item : bulkShardRequest.items()) {
+                boolean isUpdateRequest = false;
+                final IndexRequest indexRequest;
+                if (item.request() instanceof IndexRequest ir) {
+                    indexRequest = ir;
+                } else if (item.request() instanceof UpdateRequest updateRequest) {
+                    isUpdateRequest = true;
+                    if (updateRequest.script() != null) {
+                        // Skip script updates, we will error out on those later
+                        continue;
+                    }
+
+                    indexRequest = updateRequest.doc();
+                } else {
+                    // Ignore delete requests
+                    continue;
+                }
+
+                // TODO: Don't know how to avoid getting the doc map here, we need it to determine if the doc has any inference fields in it
+                // and if so, what they are
+                long estimatedEmbeddingBytes = 0;
+                boolean noInferenceSourceUpdate = false;
+                final Map<String, Object> docMap = indexRequest.sourceAsMap();
+                for (InferenceFieldMetadata inferenceFieldMetadata : fieldInferenceMap.values()) {
+                    String inferenceId = inferenceFieldMetadata.getInferenceId();
+                    MinimalServiceSettings minimalServiceSettings = null;
+
+                    if (isInferenceRequired(inferenceFieldMetadata, docMap) == false) {
+                        continue;
+                    }
+
+                    for (String sourceField : inferenceFieldMetadata.getSourceFields()) {
+                        var valueObj = XContentMapValues.extractValue(sourceField, docMap, EXPLICIT_NULL);
+                        if (useLegacyFormat == false && isUpdateRequest && valueObj == EXPLICIT_NULL) {
+                            // This is an update request that will clear inference results, which means that a copy of the request source
+                            // will be generated
+                            noInferenceSourceUpdate = true;
+                            continue;
+                        } else if (valueObj == null || valueObj == EXPLICIT_NULL) {
+                            continue;
+                        }
+
+                        final List<String> values;
+                        try {
+                            values = SemanticTextUtils.nodeStringValues(sourceField, valueObj);
+                        } catch (Exception exc) {
+                            // Skip this source field for now, the invalid value will be surfaced during field inference request generation
+                            continue;
+                        }
+
+                        for (String v : values) {
+                            if (v.isBlank()) {
+                                // We update source to insert an empty chunk list on blank input
+                                noInferenceSourceUpdate = true;
+                            } else {
+                                if (minimalServiceSettings == null) {
+                                    minimalServiceSettings = getMinimalServiceSettings(inferenceId, inferenceFieldMetadata.getName());
+                                }
+
+                                // TODO: Estimate chunk count based on string length
+                                estimatedEmbeddingBytes += switch (minimalServiceSettings.taskType()) {
+                                    case SPARSE_EMBEDDING -> 128; // TODO: Estimate sparse embedding size
+                                    case TEXT_EMBEDDING -> minimalServiceSettings.elementType()
+                                        .getNumBytes(minimalServiceSettings.dimensions());
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: Mikep86
body: Good point, I will improve this estimate
created_at: 2025-03-28T18:27:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -238,14 +251,151 @@ private AsyncBulkShardInferenceAction(
             this.fieldInferenceMap = fieldInferenceMap;
             this.bulkShardRequest = bulkShardRequest;
             this.inferenceResults = new AtomicArray<>(bulkShardRequest.items().length);
-            this.onCompletion = onCompletion;
+            this.onCompletion = () -> {
+                // TODO: Can we assume that onCompletion _always_ runs, regardless of what errors occur?
+                // Won't run if estimateMemoryUsage throws, which is the only scenario where it's ok to not run it.
+                CircuitBreaker circuitBreaker = inferenceBytesCircuitBreaker.get();
+                if (circuitBreaker != null && actualMemoryUsageInBytes > 0) {
+                    circuitBreaker.addWithoutBreaking(-actualMemoryUsageInBytes);
+                }
+
+                onCompletion.run();
+            };
         }
 
         @Override
         public void run() {
+            estimateMemoryUsage();
             executeNext(0);
         }
 
+        private void estimateMemoryUsage() {
+            CircuitBreaker circuitBreaker = inferenceBytesCircuitBreaker.get();
+            if (circuitBreaker == null) {
+                return;
+            }
+
+            for (BulkItemRequest item : bulkShardRequest.items()) {
+                boolean isUpdateRequest = false;
+                final IndexRequest indexRequest;
+                if (item.request() instanceof IndexRequest ir) {
+                    indexRequest = ir;
+                } else if (item.request() instanceof UpdateRequest updateRequest) {
+                    isUpdateRequest = true;
+                    if (updateRequest.script() != null) {
+                        // Skip script updates, we will error out on those later
+                        continue;
+                    }
+
+                    indexRequest = updateRequest.doc();
+                } else {
+                    // Ignore delete requests
+                    continue;
+                }
+
+                // TODO: Don't know how to avoid getting the doc map here, we need it to determine if the doc has any inference fields in it
+                // and if so, what they are
+                long estimatedEmbeddingBytes = 0;
+                boolean noInferenceSourceUpdate = false;
+                final Map<String, Object> docMap = indexRequest.sourceAsMap();
+                for (InferenceFieldMetadata inferenceFieldMetadata : fieldInferenceMap.values()) {
+                    String inferenceId = inferenceFieldMetadata.getInferenceId();
+                    MinimalServiceSettings minimalServiceSettings = null;
+
+                    if (isInferenceRequired(inferenceFieldMetadata, docMap) == false) {
+                        continue;
+                    }
+
+                    for (String sourceField : inferenceFieldMetadata.getSourceFields()) {
+                        var valueObj = XContentMapValues.extractValue(sourceField, docMap, EXPLICIT_NULL);
+                        if (useLegacyFormat == false && isUpdateRequest && valueObj == EXPLICIT_NULL) {
+                            // This is an update request that will clear inference results, which means that a copy of the request source
+                            // will be generated
+                            noInferenceSourceUpdate = true;
+                            continue;
+                        } else if (valueObj == null || valueObj == EXPLICIT_NULL) {
+                            continue;
+                        }
+
+                        final List<String> values;
+                        try {
+                            values = SemanticTextUtils.nodeStringValues(sourceField, valueObj);
+                        } catch (Exception exc) {
+                            // Skip this source field for now, the invalid value will be surfaced during field inference request generation
+                            continue;
+                        }
+
+                        for (String v : values) {
+                            if (v.isBlank()) {
+                                // We update source to insert an empty chunk list on blank input
+                                noInferenceSourceUpdate = true;
+                            } else {
+                                if (minimalServiceSettings == null) {
+                                    minimalServiceSettings = getMinimalServiceSettings(inferenceId, inferenceFieldMetadata.getName());
+                                }
+
+                                // TODO: Estimate chunk count based on string length
+                                estimatedEmbeddingBytes += switch (minimalServiceSettings.taskType()) {
+                                    case SPARSE_EMBEDDING -> 128; // TODO: Estimate sparse embedding size
+                                    case TEXT_EMBEDDING -> minimalServiceSettings.elementType()
+                                        .getNumBytes(minimalServiceSettings.dimensions());
+                                    default -> throw new IllegalStateException(
+                                        "Inference ID ["
+                                            + inferenceId
+                                            + "] uses unsupported task type ["
+                                            + minimalServiceSettings.taskType()
+                                            + "]"
+                                    );
+                                };
+                            }
+                        }
+                    }
+                }
+
+                // TODO: Verify that ramBytesUsed is what to use here
+                long estimatedMemoryUsage = 0;
+                long newEstimatedInferenceRequestMemoryUsageInBytes = estimatedInferenceRequestMemoryUsageInBytes;
+                long newNoInferenceRequestMemoryUsageInBytes = noInferenceRequestMemoryUsageInBytes;
+                if (estimatedEmbeddingBytes > 0) {
+                    estimatedMemoryUsage = estimatedEmbeddingBytes + indexRequest.source().ramBytesUsed();
+                    newEstimatedInferenceRequestMemoryUsageInBytes += estimatedMemoryUsage;
+                } else if (noInferenceSourceUpdate) {
+                    estimatedMemoryUsage = indexRequest.source().ramBytesUsed();
+                    newNoInferenceRequestMemoryUsageInBytes += estimatedMemoryUsage;
+                }
+
+                if (estimatedMemoryUsage > 0) {
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: Mikep86
body: Not true, we only (potentially) modify requests that set values for `semantic_text` fields (or their source fields). Other requests don't require additional memory.
created_at: 2025-03-28T18:30:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -625,6 +791,57 @@ private void applyInferenceResponses(BulkItemRequest item, FieldInferenceRespons
                 }
             }
         }
+
+        private boolean updateActualMemoryUsage(
+            FieldInferenceRequest request,
+            ChunkedInference chunkedInference,
+            FieldInferenceResponseAccumulator accumulator
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: Mikep86
body: A boolean response doesn't give us enough information about the error to report though. As you can see from the implementation, we report different error messages for `CircuitBreakingException` vs. more generic exceptions. We would have to throw an exception from `updateActualMemoryUsage`:

```java
try {
  updateActualMemoryUsage(...)
  acc.addOrUpdateResponse(...)
} catch (Exception e) {
  acc.addFailure(...)
}
```

Which is fine, i can do that. I see the benefit of such an approach, it makes it much more difficult to use the method incorrectly. 
created_at: 2025-03-28T18:42:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -625,6 +791,57 @@ private void applyInferenceResponses(BulkItemRequest item, FieldInferenceRespons
                 }
             }
         }
+
+        private boolean updateActualMemoryUsage(
+            FieldInferenceRequest request,
+            ChunkedInference chunkedInference,
+            FieldInferenceResponseAccumulator accumulator
+        ) {
+            boolean success = true;
+
+            CircuitBreaker circuitBreaker = inferenceBytesCircuitBreaker.get();
+            if (circuitBreaker == null) {
+                return success;
+            }
+
+            try {
+                final BulkItemRequest item = bulkShardRequest.items()[request.bulkItemIndex()];
+                final IndexRequest indexRequest = getIndexRequestOrNull(item.request());
+
+                long chunkBytes = 0;
+                Iterator<ChunkedInference.Chunk> chunkIterator = chunkedInference.chunksAsByteReference(
+                    indexRequest.getContentType().xContent()
+                );
+                while (chunkIterator.hasNext()) {
+                    ChunkedInference.Chunk chunk = chunkIterator.next();
+                    chunkBytes += chunk.bytesReference().ramBytesUsed();
+                }
+
+                // This doesn't count the bytes used for the inference metadata field structure (in the new format) or the field structure
+                // around the embeddings (in the legacy format), but it's a close approximation.
+                long bytesUsed = chunkBytes + indexRequest.source().ramBytesUsed();
+                if (bytesUsed <= estimatedInferenceRequestMemoryUsageInBytes) {
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: Mikep86
body: `bytesUsed` tracks the amount of additional memory used by this this request, calculated using the actual embeddings returned by the Inference API. `estimatedInferenceRequestMemoryUsageInBytes` tracks the estimated memory usage of all requests that require inference, which we calculate in `estimateMemoryUsage`. We have already pre-allocated `estimatedInferenceRequestMemoryUsageInBytes` against the circuit breaker, thus we only need to update the breaker if/when we exceed the estimate.
created_at: 2025-03-28T18:55:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -227,25 +254,138 @@ private class AsyncBulkShardInferenceAction implements Runnable {
         private final BulkShardRequest bulkShardRequest;
         private final Runnable onCompletion;
         private final AtomicArray<FieldInferenceResponseAccumulator> inferenceResults;
+        private final CoordinatingIndexingPressureWrapper coordinatingWrapper;
+
+        private long estimatedInferenceRequestMemoryUsageInBytes = 0;  // Estimated memory used by requests that perform inference
 
         private AsyncBulkShardInferenceAction(
             boolean useLegacyFormat,
             Map<String, InferenceFieldMetadata> fieldInferenceMap,
             BulkShardRequest bulkShardRequest,
-            Runnable onCompletion
+            Runnable onCompletion,
+            CoordinatingIndexingPressureWrapper coordinatingWrapper
         ) {
             this.useLegacyFormat = useLegacyFormat;
             this.fieldInferenceMap = fieldInferenceMap;
             this.bulkShardRequest = bulkShardRequest;
             this.inferenceResults = new AtomicArray<>(bulkShardRequest.items().length);
             this.onCompletion = onCompletion;
+            this.coordinatingWrapper = coordinatingWrapper;
         }
 
         @Override
         public void run() {
+            estimateMemoryUsage();
             executeNext(0);
         }
 
+        private void estimateMemoryUsage() {
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: Mikep86
body: @jimczi Based on our discussion offline, I think we could streamline and simplify the estimate logic by:

- Basing it only on the source size (i.e. don't try to estimate embedding size)
- Incrementally calculating it while generating the inference requests

The second point will allow us to calculate the estimate in `addFieldInferenceRequests`, which will simplify the implementation considerably. The only downside of this approach is we lose the "all or none" nature of calculating the full estimate before performing any inference. If we incrementally calculate the estimate on each batch, we could partially process and index a bulk request before failing on the estimate exceeding the threshold. I think this could be fine though, as this is already the failure mode for exceeding the threshold when calculating the actual memory usage.

WDYT?
created_at: 2025-04-02T13:39:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -227,25 +254,138 @@ private class AsyncBulkShardInferenceAction implements Runnable {
         private final BulkShardRequest bulkShardRequest;
         private final Runnable onCompletion;
         private final AtomicArray<FieldInferenceResponseAccumulator> inferenceResults;
+        private final CoordinatingIndexingPressureWrapper coordinatingWrapper;
+
+        private long estimatedInferenceRequestMemoryUsageInBytes = 0;  // Estimated memory used by requests that perform inference
 
         private AsyncBulkShardInferenceAction(
             boolean useLegacyFormat,
             Map<String, InferenceFieldMetadata> fieldInferenceMap,
             BulkShardRequest bulkShardRequest,
-            Runnable onCompletion
+            Runnable onCompletion,
+            CoordinatingIndexingPressureWrapper coordinatingWrapper
         ) {
             this.useLegacyFormat = useLegacyFormat;
             this.fieldInferenceMap = fieldInferenceMap;
             this.bulkShardRequest = bulkShardRequest;
             this.inferenceResults = new AtomicArray<>(bulkShardRequest.items().length);
             this.onCompletion = onCompletion;
+            this.coordinatingWrapper = coordinatingWrapper;
         }
 
         @Override
         public void run() {
+            estimateMemoryUsage();
             executeNext(0);
         }
 
+        private void estimateMemoryUsage() {
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: jimczi
body: +1 to simplify, we can work at the batch level here. Before executing a batch, we add the size of all the sources in the batch in the memory usage since we know that we'll need this memory soon. 
At the end of the batch, we can update our estimation by taking the real source size (after adding the embeddings). If we don't enough memory to keep the embeddings response, we should rely on the fact that the inference request is also subject to circuit breakers.
created_at: 2025-04-02T21:55:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -227,25 +254,138 @@ private class AsyncBulkShardInferenceAction implements Runnable {
         private final BulkShardRequest bulkShardRequest;
         private final Runnable onCompletion;
         private final AtomicArray<FieldInferenceResponseAccumulator> inferenceResults;
+        private final CoordinatingIndexingPressureWrapper coordinatingWrapper;
+
+        private long estimatedInferenceRequestMemoryUsageInBytes = 0;  // Estimated memory used by requests that perform inference
 
         private AsyncBulkShardInferenceAction(
             boolean useLegacyFormat,
             Map<String, InferenceFieldMetadata> fieldInferenceMap,
             BulkShardRequest bulkShardRequest,
-            Runnable onCompletion
+            Runnable onCompletion,
+            CoordinatingIndexingPressureWrapper coordinatingWrapper
         ) {
             this.useLegacyFormat = useLegacyFormat;
             this.fieldInferenceMap = fieldInferenceMap;
             this.bulkShardRequest = bulkShardRequest;
             this.inferenceResults = new AtomicArray<>(bulkShardRequest.items().length);
             this.onCompletion = onCompletion;
+            this.coordinatingWrapper = coordinatingWrapper;
         }
 
         @Override
         public void run() {
+            estimateMemoryUsage();
             executeNext(0);
         }
 
+        private void estimateMemoryUsage() {
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: Mikep86
body: Sounds good. Does the inference request circuit breaker also consider the longer lifetime of the embeddings response? What I mean by that is that we take the embeddings response and stick it into a `FieldInferenceResponse`, extending its lifetime beyond the listener's `onResponse` handler. Is that something we need to account for?
created_at: 2025-04-03T19:43:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -227,25 +254,138 @@ private class AsyncBulkShardInferenceAction implements Runnable {
         private final BulkShardRequest bulkShardRequest;
         private final Runnable onCompletion;
         private final AtomicArray<FieldInferenceResponseAccumulator> inferenceResults;
+        private final CoordinatingIndexingPressureWrapper coordinatingWrapper;
+
+        private long estimatedInferenceRequestMemoryUsageInBytes = 0;  // Estimated memory used by requests that perform inference
 
         private AsyncBulkShardInferenceAction(
             boolean useLegacyFormat,
             Map<String, InferenceFieldMetadata> fieldInferenceMap,
             BulkShardRequest bulkShardRequest,
-            Runnable onCompletion
+            Runnable onCompletion,
+            CoordinatingIndexingPressureWrapper coordinatingWrapper
         ) {
             this.useLegacyFormat = useLegacyFormat;
             this.fieldInferenceMap = fieldInferenceMap;
             this.bulkShardRequest = bulkShardRequest;
             this.inferenceResults = new AtomicArray<>(bulkShardRequest.items().length);
             this.onCompletion = onCompletion;
+            this.coordinatingWrapper = coordinatingWrapper;
         }
 
         @Override
         public void run() {
+            estimateMemoryUsage();
             executeNext(0);
         }
 
+        private void estimateMemoryUsage() {
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: jimczi
body: I was thinking that we can nullify the embeddings from the response as we go. So we do one source at a time, perform the transformation, add the additional memory required and finally we make the embeddings from the response available for the GC. Does that make sense?
created_at: 2025-04-03T20:50:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -227,25 +254,138 @@ private class AsyncBulkShardInferenceAction implements Runnable {
         private final BulkShardRequest bulkShardRequest;
         private final Runnable onCompletion;
         private final AtomicArray<FieldInferenceResponseAccumulator> inferenceResults;
+        private final CoordinatingIndexingPressureWrapper coordinatingWrapper;
+
+        private long estimatedInferenceRequestMemoryUsageInBytes = 0;  // Estimated memory used by requests that perform inference
 
         private AsyncBulkShardInferenceAction(
             boolean useLegacyFormat,
             Map<String, InferenceFieldMetadata> fieldInferenceMap,
             BulkShardRequest bulkShardRequest,
-            Runnable onCompletion
+            Runnable onCompletion,
+            CoordinatingIndexingPressureWrapper coordinatingWrapper
         ) {
             this.useLegacyFormat = useLegacyFormat;
             this.fieldInferenceMap = fieldInferenceMap;
             this.bulkShardRequest = bulkShardRequest;
             this.inferenceResults = new AtomicArray<>(bulkShardRequest.items().length);
             this.onCompletion = onCompletion;
+            this.coordinatingWrapper = coordinatingWrapper;
         }
 
         @Override
         public void run() {
+            estimateMemoryUsage();
             executeNext(0);
         }
 
+        private void estimateMemoryUsage() {
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: Mikep86
body: I think that would require refactoring how we use `FieldInferenceResponse`? I'll look into this.
created_at: 2025-04-03T21:03:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -227,25 +254,138 @@ private class AsyncBulkShardInferenceAction implements Runnable {
         private final BulkShardRequest bulkShardRequest;
         private final Runnable onCompletion;
         private final AtomicArray<FieldInferenceResponseAccumulator> inferenceResults;
+        private final CoordinatingIndexingPressureWrapper coordinatingWrapper;
+
+        private long estimatedInferenceRequestMemoryUsageInBytes = 0;  // Estimated memory used by requests that perform inference
 
         private AsyncBulkShardInferenceAction(
             boolean useLegacyFormat,
             Map<String, InferenceFieldMetadata> fieldInferenceMap,
             BulkShardRequest bulkShardRequest,
-            Runnable onCompletion
+            Runnable onCompletion,
+            CoordinatingIndexingPressureWrapper coordinatingWrapper
         ) {
             this.useLegacyFormat = useLegacyFormat;
             this.fieldInferenceMap = fieldInferenceMap;
             this.bulkShardRequest = bulkShardRequest;
             this.inferenceResults = new AtomicArray<>(bulkShardRequest.items().length);
             this.onCompletion = onCompletion;
+            this.coordinatingWrapper = coordinatingWrapper;
         }
 
         @Override
         public void run() {
+            estimateMemoryUsage();
             executeNext(0);
         }
 
+        private void estimateMemoryUsage() {
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: jimczi
body: Sorry that wasn't clear, what I describe is what we already do. See [here](https://github.com/elastic/elasticsearch/blob/950456d38b5c392959f082842f39d836457cb3ad/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java#L284) where we get rid of all the responses for a single document after the source is rewritten.
What's missing is to add the memory usage to the CB before nullifying the responses. 

created_at: 2025-04-04T00:49:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -227,25 +254,138 @@ private class AsyncBulkShardInferenceAction implements Runnable {
         private final BulkShardRequest bulkShardRequest;
         private final Runnable onCompletion;
         private final AtomicArray<FieldInferenceResponseAccumulator> inferenceResults;
+        private final CoordinatingIndexingPressureWrapper coordinatingWrapper;
+
+        private long estimatedInferenceRequestMemoryUsageInBytes = 0;  // Estimated memory used by requests that perform inference
 
         private AsyncBulkShardInferenceAction(
             boolean useLegacyFormat,
             Map<String, InferenceFieldMetadata> fieldInferenceMap,
             BulkShardRequest bulkShardRequest,
-            Runnable onCompletion
+            Runnable onCompletion,
+            CoordinatingIndexingPressureWrapper coordinatingWrapper
         ) {
             this.useLegacyFormat = useLegacyFormat;
             this.fieldInferenceMap = fieldInferenceMap;
             this.bulkShardRequest = bulkShardRequest;
             this.inferenceResults = new AtomicArray<>(bulkShardRequest.items().length);
             this.onCompletion = onCompletion;
+            this.coordinatingWrapper = coordinatingWrapper;
         }
 
         @Override
         public void run() {
+            estimateMemoryUsage();
             executeNext(0);
         }
 
+        private void estimateMemoryUsage() {
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: Mikep86
body: I assumed that's what you meant, but isn't that done by batch, not by source? It's probably fine given ML's changes to merge chunks and our batch sizing, but I just want to clarify that I'm understanding the logic there correctly.
created_at: 2025-04-04T12:08:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -545,9 +584,56 @@ private long addFieldInferenceRequests(BulkItemRequest item, int itemIndex, Map<
                     }
                 }
             }
+
             return inputLength;
         }
 
+        private static class IndexRequestWithIndexingPressure {
+            private final IndexRequest indexRequest;
+            private boolean indexingPressureIncremented;
+
+            private IndexRequestWithIndexingPressure(IndexRequest indexRequest) {
+                this.indexRequest = indexRequest;
+                this.indexingPressureIncremented = false;
+            }
+
+            private IndexRequest getIndexRequest() {
+                return indexRequest;
+            }
+
+            private boolean isIndexingPressureIncremented() {
+                return indexingPressureIncremented;
+            }
+
+            private void setIndexingPressureIncremented() {
+                this.indexingPressureIncremented = true;
+            }
+        }
+
+        private boolean incrementIndexingPressure(IndexRequestWithIndexingPressure indexRequest, int itemIndex) {
+            boolean success = true;
+            IndexingPressure.Coordinating coordinatingIndexingPressure = coordinatingWrapper.coordinating();
+            if (coordinatingIndexingPressure != null && indexRequest.isIndexingPressureIncremented() == false) {
+                try {
+                    // Track operation count as one operation per document source update
+                    coordinatingIndexingPressure.increment(1, indexRequest.getIndexRequest().source().ramBytesUsed());
+                    indexRequest.setIndexingPressureIncremented();
+                } catch (EsRejectedExecutionException e) {
+                    // TODO: Safe to assume ID is set/generated at this point?
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: Mikep86
body: I think it's OK to assume document ID is generated by now? But wanted to check.
created_at: 2025-04-04T21:18:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -129,6 +134,10 @@ private void setBatchSize(ByteSizeValue newBatchSize) {
         batchSizeInBytes = newBatchSize.getBytes();
     }
 
+    public void setIndexingPressure(IndexingPressure indexingPressure) {
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: jimczi
body: Why not setting it in the constructor?
created_at: 2025-04-07T08:52:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -545,9 +584,56 @@ private long addFieldInferenceRequests(BulkItemRequest item, int itemIndex, Map<
                     }
                 }
             }
+
             return inputLength;
         }
 
+        private static class IndexRequestWithIndexingPressure {
+            private final IndexRequest indexRequest;
+            private boolean indexingPressureIncremented;
+
+            private IndexRequestWithIndexingPressure(IndexRequest indexRequest) {
+                this.indexRequest = indexRequest;
+                this.indexingPressureIncremented = false;
+            }
+
+            private IndexRequest getIndexRequest() {
+                return indexRequest;
+            }
+
+            private boolean isIndexingPressureIncremented() {
+                return indexingPressureIncremented;
+            }
+
+            private void setIndexingPressureIncremented() {
+                this.indexingPressureIncremented = true;
+            }
+        }
+
+        private boolean incrementIndexingPressure(IndexRequestWithIndexingPressure indexRequest, int itemIndex) {
+            boolean success = true;
+            IndexingPressure.Coordinating coordinatingIndexingPressure = coordinatingWrapper.coordinating();
+            if (coordinatingIndexingPressure != null && indexRequest.isIndexingPressureIncremented() == false) {
+                try {
+                    // Track operation count as one operation per document source update
+                    coordinatingIndexingPressure.increment(1, indexRequest.getIndexRequest().source().ramBytesUsed());
+                    indexRequest.setIndexingPressureIncremented();
+                } catch (EsRejectedExecutionException e) {
+                    // TODO: Safe to assume ID is set/generated at this point?
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: jimczi
body: This should be ok, that's a doc failure though so it will be returned in the response as such.
created_at: 2025-04-07T09:04:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -186,6 +187,11 @@ public interface PluginServices {
          * Provider for additional SlowLog fields
          */
         SlowLogFieldProvider slowLogFieldProvider();
+
+        /**
+         * Provider for indexing pressure
+         */
+        IndexingPressure indexingPressure();
path: server/src/main/java/org/elasticsearch/plugins/Plugin.java
user: login: ldematte
body: Why do we need to add this to PluginServices? Can't this be done in the standard way? (via CircuitBreakerService) or in another way that does not add yet another service here?
created_at: 2025-04-07T12:10:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -129,6 +134,10 @@ private void setBatchSize(ByteSizeValue newBatchSize) {
         batchSizeInBytes = newBatchSize.getBytes();
     }
 
+    public void setIndexingPressure(IndexingPressure indexingPressure) {
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: kderusso
body: ++ also I'm unclear why this has to be a `SetOnce` rather than just passing in the indexing pressure 
created_at: 2025-04-07T12:17:54Z
author_association: MEMBER
diff_hunk: @@ -129,6 +134,10 @@ private void setBatchSize(ByteSizeValue newBatchSize) {
         batchSizeInBytes = newBatchSize.getBytes();
     }
 
+    public void setIndexingPressure(IndexingPressure indexingPressure) {
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: Mikep86
body: The idea is this approach makes it easier to test. You only need to set the indexing pressure on tests related to it. I can pass it in through the constructor, but more test code will need to change.
created_at: 2025-04-07T12:58:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -186,6 +187,11 @@ public interface PluginServices {
          * Provider for additional SlowLog fields
          */
         SlowLogFieldProvider slowLogFieldProvider();
+
+        /**
+         * Provider for indexing pressure
+         */
+        IndexingPressure indexingPressure();
path: server/src/main/java/org/elasticsearch/plugins/Plugin.java
user: login: Mikep86
body: This isn't a circuit breaker though, so we can't use the `CircuitBreakerService` pattern. We could theoretically create some new `IndexingPressureService` pattern, but I think that would result in more changes and complexity for the same end result.
created_at: 2025-04-07T13:00:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -186,6 +187,11 @@ public interface PluginServices {
          * Provider for additional SlowLog fields
          */
         SlowLogFieldProvider slowLogFieldProvider();
+
+        /**
+         * Provider for indexing pressure
+         */
+        IndexingPressure indexingPressure();
path: server/src/main/java/org/elasticsearch/plugins/Plugin.java
user: login: ldematte
body: I got confused by the title, and assumed `IndexingPressure` was using a CircuitBreaker internally that it could expose. Is this using a different mechanism?
created_at: 2025-04-07T13:15:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -186,6 +187,11 @@ public interface PluginServices {
          * Provider for additional SlowLog fields
          */
         SlowLogFieldProvider slowLogFieldProvider();
+
+        /**
+         * Provider for indexing pressure
+         */
+        IndexingPressure indexingPressure();
path: server/src/main/java/org/elasticsearch/plugins/Plugin.java
user: login: Mikep86
body: Yes, it is using [coordinating indexing pressure](https://github.com/elastic/elasticsearch/blob/4a2976ccaba43ff74983bf85230784c7fe1275c0/server/src/main/java/org/elasticsearch/index/IndexingPressure.java#L250), which was previously only used internally in the bulk action. We decided to extend its usage to here as well because the inference performed in `ShardBulkInferenceActionFilter` increases indexing pressure.
created_at: 2025-04-07T13:23:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -129,6 +134,10 @@ private void setBatchSize(ByteSizeValue newBatchSize) {
         batchSizeInBytes = newBatchSize.getBytes();
     }
 
+    public void setIndexingPressure(IndexingPressure indexingPressure) {
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: jan-elastic
body: I think it's much cleaner to pass it in the constructor. For tests that don't use it, pass a no-op circuit breaker.

In this way, test code doesn't leak into the production code, and you can remove checks like `if (localIndexingPressure != null)`, which look pretty odd in the production code.
created_at: 2025-04-08T07:23:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -545,9 +584,56 @@ private long addFieldInferenceRequests(BulkItemRequest item, int itemIndex, Map<
                     }
                 }
             }
+
             return inputLength;
         }
 
+        private static class IndexRequestWithIndexingPressure {
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: jan-elastic
body: What happened to the rough estimation on beforehand?

I understand that this is much simpler, but it feels bad to start a very resource-heavy process and fail halfway through, when a simple back-of-the-envelope calculation could have shown that it was destined to fail...
created_at: 2025-04-08T07:34:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -489,6 +519,10 @@ private long addFieldInferenceRequests(BulkItemRequest item, int itemIndex, Map<
                          * This ensures that the field is treated as intentionally cleared,
                          * preventing any unintended carryover of prior inference results.
                          */
+                        if (incrementIndexingPressure(indexRequest, itemIndex) == false) {
+                            return inputLength;
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: davidkyle
body: Once this happened should all the subsequent bulk items be failed?  There is a chance that indexing pressure may have dropped sufficiently to allow a later request but does complicate the work the caller has to do processing rejected items in the bulk response?
created_at: 2025-04-08T10:52:29Z
author_association: MEMBER
diff_hunk: @@ -545,9 +584,56 @@ private long addFieldInferenceRequests(BulkItemRequest item, int itemIndex, Map<
                     }
                 }
             }
+
             return inputLength;
         }
 
+        private static class IndexRequestWithIndexingPressure {
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: davidkyle
body: It should be simple to follow up add an estimated cost of the inference response to the initial call to `incrementIndexingPressure()`. We can follow up with that.
created_at: 2025-04-08T10:54:41Z
author_association: MEMBER
diff_hunk: @@ -545,9 +584,56 @@ private long addFieldInferenceRequests(BulkItemRequest item, int itemIndex, Map<
                     }
                 }
             }
+
             return inputLength;
         }
 
+        private static class IndexRequestWithIndexingPressure {
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: jan-elastic
body: I'm totally fine to do that as a follow-up, but I think it's something we should do.

For each request, add an estimate to the circuit breaker upfront. When we get the real value, adjust the circuit breaker accordingly.
created_at: 2025-04-08T11:02:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -489,6 +519,10 @@ private long addFieldInferenceRequests(BulkItemRequest item, int itemIndex, Map<
                          * This ensures that the field is treated as intentionally cleared,
                          * preventing any unintended carryover of prior inference results.
                          */
+                        if (incrementIndexingPressure(indexRequest, itemIndex) == false) {
+                            return inputLength;
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: Mikep86
body: I would say no for two reasons:

- The caller already needs to handle the case of mixed results due to `BulkItemRequest`s that do not require inference result injection. We do not touch those requests and they will not be affected by this circuit breaker logic.
- A later `BulkItemRequest` may succeed due to the source being smaller or having smaller embeddings. Given that the caller already has to handle mixed results due to the point above, it makes sense to allow inference result injections that can succeed.
created_at: 2025-04-08T12:21:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -545,9 +584,56 @@ private long addFieldInferenceRequests(BulkItemRequest item, int itemIndex, Map<
                     }
                 }
             }
+
             return inputLength;
         }
 
+        private static class IndexRequestWithIndexingPressure {
path: x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilter.java
user: login: Mikep86
body: I'm all for integrating an estimated cost of the inference response in a follow-up. However, we should stick with the batch-based processing implemented in this PR. Not only is it simpler, but it avoids duplicating expensive operations like parsing the source to a map.
created_at: 2025-04-08T12:24:24Z
author_association: CONTRIBUTOR
path: x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/action/filter/ShardBulkInferenceActionFilterTests.java
user: login: Mikep86
body: Reminder to myself to add a partial failure test
created_at: 2025-04-08T12:25:32Z
author_association: CONTRIBUTOR

PR comments:
user: login: Tim-Brooks
created_at: 2025-03-25T16:51:44Z
body: > We don't need to add a new circuit breaker here. The bulk action already includes an [IndexingPressure](https://github.com/elastic/elasticsearch/blob/3b685cb27a73f39ad491a3563bff52e93d572f0f/server/src/main/java/org/elasticsearch/index/IndexingPressure.java#L26) object, which looks promising. There's even a [TODO](https://github.com/elastic/elasticsearch/blob/3b685cb27a73f39ad491a3563bff52e93d572f0f/server/src/main/java/org/elasticsearch/index/IndexingPressure.java#L244) to use it for ingest pipelines, so I believe we should follow that approach.
Can @elastic/es-distributed-indexing provide some guidance on this?

Yes. This is a use case for `IndexingPressure`. I think that what is happening here is "coordinating"? Which means that it should be under the coordinating step. 
author_association: CONTRIBUTOR
user: login: Tim-Brooks
created_at: 2025-03-25T17:00:17Z
body: I believe that the way this code works currently is:

1. You have the original source bytes (hypothetically: 1KB)
2. You apply some inference expansion (hypothetically: turns the source into 3KB)

As I understand the code currently, we need to increment indexing pressure by 3KB to account for the newly created sources.
author_association: CONTRIBUTOR
user: login: jimczi
created_at: 2025-03-25T17:38:46Z
body: Thanks @Tim-Brooks, that's exactly that. We're at the bulk shard request level but that shouldn't be a problem right? 
author_association: CONTRIBUTOR
user: login: Tim-Brooks
created_at: 2025-03-25T18:04:48Z
body: Yeah. Should be fine. IMO this still qualifies as coordinating bytes (opposed to primary) because it is prior to the reroute step and still occurring on the coordinating node (before it network hops to the primary if that is a different node). 

Assuming I am not misunderstanding something.
author_association: CONTRIBUTOR
user: login: jimczi
created_at: 2025-03-25T18:47:16Z
body: > Assuming I am not misunderstanding something.

You're not, the full expansion is done on the coordinating node.
author_association: CONTRIBUTOR
user: login: Mikep86
created_at: 2025-04-02T16:03:39Z
body: @elasticmachine update branch
author_association: CONTRIBUTOR
user: login: elasticsearchmachine
created_at: 2025-04-04T21:20:46Z
body: Pinging @elastic/ml-core (Team:ML)
author_association: COLLABORATOR
user: login: elasticsearchmachine
created_at: 2025-04-04T21:20:47Z
body: Pinging @elastic/search-eng (Team:SearchOrg)
author_association: COLLABORATOR
user: login: elasticsearchmachine
created_at: 2025-04-04T21:20:47Z
body: Pinging @elastic/search-relevance (Team:Search - Relevance)
author_association: COLLABORATOR
user: login: Mikep86
created_at: 2025-04-04T21:50:15Z
body: @elasticmachine update branch
author_association: CONTRIBUTOR
user: login: Mikep86
created_at: 2025-04-07T13:28:48Z
body: @elasticmachine Update branch
author_association: CONTRIBUTOR
user: login: Mikep86
created_at: 2025-04-07T13:29:24Z
body: @elasticmachine update branch
author_association: CONTRIBUTOR
user: login: Mikep86
created_at: 2025-04-08T14:03:51Z
body: @elasticmachine update branch
author_association: CONTRIBUTOR
user: login: Mikep86
created_at: 2025-04-08T18:43:43Z
body: @elasticmachine update branch
author_association: CONTRIBUTOR
user: login: elasticsearchmachine
created_at: 2025-04-11T07:41:50Z
body: Hi @Mikep86, I've created a changelog YAML for you.
author_association: COLLABORATOR
user: login: elasticsearchmachine
created_at: 2025-04-11T07:42:23Z
body: Pinging @elastic/es-distributed-indexing (Team:Distributed Indexing)
author_association: COLLABORATOR
user: login: elasticsearchmachine
created_at: 2025-04-14T19:56:54Z
body: ## 💔 Backport failed
| Status | Branch | Result |
|:------:|:------:|:------:|
| ❌ |  8.x  | Commit could not be cherrypicked due to conflicts |

You can use [sqren/backport](https://github.com/sqren/backport) to manually backport by running `backport --upstream elastic/elasticsearch --pr 125517`
author_association: COLLABORATOR
user: login: Mikep86
created_at: 2025-04-28T12:30:25Z
body: ## 💚 All backports created successfully

| Status | Branch | Result |
|:------:|:------:|:------|
|✅|8.19|[<img src="https://img.shields.io/github/pulls/detail/state/elastic/elasticsearch/127463">](https://github.com/elastic/elasticsearch/pull/127463)|

### Questions ?
Please refer to the [Backport tool documentation](https://github.com/sqren/backport)

<!--- Backport version: 9.4.1 -->
author_association: CONTRIBUTOR