This is an automated email from the ASF dual-hosted git repository.

jrmccluskey pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git


The following commit(s) were added to refs/heads/master by this push:
     new 27d699eb721 [Gemini] Add client-side throttling to Java Remote 
Inference (#39194)
27d699eb721 is described below

commit 27d699eb7215ca4ebda8d61984c17507e2937e87
Author: Jack McCluskey <[email protected]>
AuthorDate: Wed Jul 8 09:08:55 2026 -0400

    [Gemini] Add client-side throttling to Java Remote Inference (#39194)
    
    * [Gemini] Add client-side throttling to Java Remote Inference
    
    * Expose other configuration fields
    
    * Apply suggestions from code review
    
    Co-authored-by: gemini-code-assist[bot] 
<176961590+gemini-code-assist[bot]@users.noreply.github.com>
    
    * argument validation
    
    * unit test fix
    
    * Docstrings
    
    * disable on 0 throttlingDelaySecs
    
    ---------
    
    Co-authored-by: gemini-code-assist[bot] 
<176961590+gemini-code-assist[bot]@users.noreply.github.com>
---
 sdks/java/ml/inference/remote/build.gradle         |   1 +
 .../sdk/ml/inference/remote/RemoteInference.java   | 102 +++++++++++++++++++--
 .../ml/inference/remote/RemoteInferenceTest.java   | 100 ++++++++++++++++++++
 3 files changed, 194 insertions(+), 9 deletions(-)

diff --git a/sdks/java/ml/inference/remote/build.gradle 
b/sdks/java/ml/inference/remote/build.gradle
index 7e7bb61c959..6c2f4e0b617 100644
--- a/sdks/java/ml/inference/remote/build.gradle
+++ b/sdks/java/ml/inference/remote/build.gradle
@@ -29,6 +29,7 @@ ext.summary = "Base framework for remote ml inference"
 dependencies {
   // Core Beam SDK
   implementation project(path: ":sdks:java:core", configuration: "shadow")
+  implementation project(":sdks:java:io:components")
 
   compileOnly "com.google.auto.value:auto-value-annotations:1.11.0"
   annotationProcessor "com.google.auto.value:auto-value:1.11.0"
diff --git 
a/sdks/java/ml/inference/remote/src/main/java/org/apache/beam/sdk/ml/inference/remote/RemoteInference.java
 
b/sdks/java/ml/inference/remote/src/main/java/org/apache/beam/sdk/ml/inference/remote/RemoteInference.java
index 193c83d7b3e..c571911a484 100644
--- 
a/sdks/java/ml/inference/remote/src/main/java/org/apache/beam/sdk/ml/inference/remote/RemoteInference.java
+++ 
b/sdks/java/ml/inference/remote/src/main/java/org/apache/beam/sdk/ml/inference/remote/RemoteInference.java
@@ -21,6 +21,7 @@ import static 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Pr
 
 import com.google.auto.value.AutoValue;
 import java.util.List;
+import org.apache.beam.sdk.io.components.throttling.ReactiveThrottler;
 import org.apache.beam.sdk.transforms.BatchElements;
 import org.apache.beam.sdk.transforms.DoFn;
 import org.apache.beam.sdk.transforms.PTransform;
@@ -48,15 +49,12 @@ import org.checkerframework.checker.nullness.qual.Nullable;
  * // Apply remote inference transform
  * PCollection<OpenAIModelInput> inputs = pipeline.apply(Create.of(
  *     OpenAIModelInput.create("An excellent B2B SaaS solution that 
streamlines business processes efficiently."),
- *     OpenAIModelInput.create("Really impressed with the innovative 
features!")
- * ));
+ *     OpenAIModelInput.create("Really impressed with the innovative 
features!")));
  *
- * PCollection<Iterable<PredictionResult<OpenAIModelInput, 
OpenAIModelResponse>>> results =
- *     inputs.apply(
- *         RemoteInference.<OpenAIModelInput, OpenAIModelResponse>invoke()
- *             .handler(OpenAIModelHandler.class)
- *             .withParameters(params)
- *     );
+ * PCollection<Iterable<PredictionResult<OpenAIModelInput, 
OpenAIModelResponse>>> results = inputs.apply(
+ *     RemoteInference.<OpenAIModelInput, OpenAIModelResponse>invoke()
+ *         .handler(OpenAIModelHandler.class)
+ *         .withParameters(params));
  * }</pre>
  */
 @SuppressWarnings({"rawtypes", "unchecked"})
@@ -82,6 +80,14 @@ public class RemoteInference {
 
     abstract BatchElements.@Nullable BatchConfig batchConfig();
 
+    abstract @Nullable Integer throttleDelaySecs();
+
+    abstract @Nullable Long samplePeriodMs();
+
+    abstract @Nullable Long sampleUpdateMs();
+
+    abstract @Nullable Double overloadRatio();
+
     abstract Builder<InputT, OutputT> builder();
 
     @AutoValue.Builder
@@ -94,6 +100,14 @@ public class RemoteInference {
 
       abstract Builder<InputT, OutputT> 
setBatchConfig(BatchElements.BatchConfig batchConfig);
 
+      abstract Builder<InputT, OutputT> setThrottleDelaySecs(Integer 
throttleDelaySecs);
+
+      abstract Builder<InputT, OutputT> setSamplePeriodMs(Long samplePeriodMs);
+
+      abstract Builder<InputT, OutputT> setSampleUpdateMs(Long sampleUpdateMs);
+
+      abstract Builder<InputT, OutputT> setOverloadRatio(Double overloadRatio);
+
       abstract Invoke<InputT, OutputT> build();
     }
 
@@ -113,6 +127,42 @@ public class RemoteInference {
       return builder().setBatchConfig(batchConfig).build();
     }
 
+    /**
+     * Configures the throttling delay when the client is preemptively 
throttled. Defaults to 5
+     * seconds. A value of 0 disables throttling. For more context, see {@link 
ReactiveThrottler}
+     */
+    public Invoke<InputT, OutputT> withThrottleDelaySecs(int 
throttleDelaySecs) {
+      checkArgument(throttleDelaySecs >= 0, "throttleDelaySecs must be 
non-negative");
+      return builder().setThrottleDelaySecs(throttleDelaySecs).build();
+    }
+
+    /**
+     * Configures the length of history to consider when setting throttling 
probability. Defaults to
+     * a sample period of 1000ms. For more context, see {@link 
AdaptiveThrottler}
+     */
+    public Invoke<InputT, OutputT> withSamplePeriodMs(long samplePeriodMs) {
+      checkArgument(samplePeriodMs > 0, "samplePeriodMs must be positive");
+      return builder().setSamplePeriodMs(samplePeriodMs).build();
+    }
+
+    /**
+     * Configures the granularity of time buckets that we store data in for 
throttling. Defaults to
+     * a sample period of 1000ms. For more context, see {@link 
AdaptiveThrottler}
+     */
+    public Invoke<InputT, OutputT> withSampleUpdateMs(long sampleUpdateMs) {
+      checkArgument(sampleUpdateMs > 0, "sampleUpdateMs must be positive");
+      return builder().setSampleUpdateMs(sampleUpdateMs).build();
+    }
+
+    /**
+     * Configures the target ratio between requests sent and successful 
requests. Defaults to an
+     * overload ratio of 2.0. For more context, see {@link AdaptiveThrottler}
+     */
+    public Invoke<InputT, OutputT> withOverloadRatio(double overloadRatio) {
+      checkArgument(overloadRatio > 0, "overloadRatio must be positive");
+      return builder().setOverloadRatio(overloadRatio).build();
+    }
+
     @Override
     public PCollection<Iterable<PredictionResult<InputT, OutputT>>> expand(
         PCollection<InputT> input) {
@@ -151,10 +201,19 @@ public class RemoteInference {
       private final BaseModelParameters parameters;
       private transient @Nullable BaseModelHandler modelHandler;
       private final RetryHandler retryHandler;
+      private final int throttleDelaySecs;
+      private final long samplePeriodMs;
+      private final long sampleUpdateMs;
+      private final double overloadRatio;
+      private transient @Nullable ReactiveThrottler throttler;
 
       RemoteInferenceFn(Invoke<InputT, OutputT> spec) {
         this.handlerClass = spec.handler();
         this.parameters = spec.parameters();
+        this.throttleDelaySecs = spec.throttleDelaySecs() != null ? 
spec.throttleDelaySecs() : 5;
+        this.samplePeriodMs = spec.samplePeriodMs() != null ? 
spec.samplePeriodMs() : 1000L;
+        this.sampleUpdateMs = spec.sampleUpdateMs() != null ? 
spec.sampleUpdateMs() : 1000L;
+        this.overloadRatio = spec.overloadRatio() != null ? 
spec.overloadRatio() : 2.0;
         retryHandler = RetryHandler.withDefaults();
       }
 
@@ -164,15 +223,40 @@ public class RemoteInference {
         try {
           this.modelHandler = 
handlerClass.getDeclaredConstructor().newInstance();
           this.modelHandler.createClient(parameters);
+          if (throttleDelaySecs > 0) {
+            this.throttler =
+                new ReactiveThrottler(
+                    samplePeriodMs,
+                    sampleUpdateMs,
+                    overloadRatio,
+                    "RemoteInference",
+                    throttleDelaySecs);
+          }
         } catch (Exception e) {
           throw new RuntimeException("Failed to instantiate handler: " + 
handlerClass.getName(), e);
         }
       }
+
       /** Perform Inference. */
       @ProcessElement
       public void processElement(ProcessContext c) throws Exception {
         Iterable<PredictionResult<InputT, OutputT>> response =
-            retryHandler.execute(() -> modelHandler.request(c.element()));
+            retryHandler.execute(
+                () -> {
+                  if (throttler != null) {
+                    throttler.throttle();
+                  }
+                  long reqTime = System.currentTimeMillis();
+                  if (modelHandler == null) {
+                    throw new IllegalStateException("modelHandler is not 
initialized");
+                  }
+                  Iterable<PredictionResult<InputT, OutputT>> result =
+                      modelHandler.request(c.element());
+                  if (throttler != null) {
+                    throttler.successfulRequest(reqTime);
+                  }
+                  return result;
+                });
         c.output(response);
       }
     }
diff --git 
a/sdks/java/ml/inference/remote/src/test/java/org/apache/beam/sdk/ml/inference/remote/RemoteInferenceTest.java
 
b/sdks/java/ml/inference/remote/src/test/java/org/apache/beam/sdk/ml/inference/remote/RemoteInferenceTest.java
index 261b87bfe3a..64691aa1fed 100644
--- 
a/sdks/java/ml/inference/remote/src/test/java/org/apache/beam/sdk/ml/inference/remote/RemoteInferenceTest.java
+++ 
b/sdks/java/ml/inference/remote/src/test/java/org/apache/beam/sdk/ml/inference/remote/RemoteInferenceTest.java
@@ -23,6 +23,7 @@ import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
@@ -256,6 +257,30 @@ public class RemoteInferenceTest {
     }
   }
 
+  // Mock handler that fails repeatedly but eventually succeeds to trigger 
throttling
+  public static class MockThrottlingHandler
+      implements BaseModelHandler<TestParameters, TestInput, TestOutput> {
+
+    private int requestCount = 0;
+
+    @Override
+    public void createClient(TestParameters parameters) {}
+
+    @Override
+    public Iterable<PredictionResult<TestInput, TestOutput>> 
request(List<TestInput> input) {
+      requestCount++;
+      // Fail 2 out of 3 requests. RetryHandler defaults to 3 max retries,
+      // so the 3rd attempt will succeed, avoiding pipeline failure while
+      // accumulating enough failures to trigger client-side throttling.
+      if (requestCount % 3 != 0) {
+        throw new RuntimeException("Intentional failure to trigger 
throttling");
+      }
+      return input.stream()
+          .map(i -> PredictionResult.create(i, new TestOutput("processed-" + 
i.getModelInput())))
+          .collect(Collectors.toList());
+    }
+  }
+
   private static boolean containsMessage(Throwable e, String message) {
     Throwable current = e;
     while (current != null) {
@@ -617,4 +642,79 @@ public class RemoteInferenceTest {
         "Expected message to contain 'handler() is required', but got: " + 
thrown.getMessage(),
         thrown.getMessage().contains("handler() is required"));
   }
+
+  @Test
+  public void testThrottlingBehavior() {
+    TestParameters params = 
TestParameters.builder().setConfig("test-config").build();
+
+    // Create enough inputs to ensure throttling probabilistically triggers
+    List<TestInput> inputs = new ArrayList<>();
+    for (int i = 0; i < 30; i++) {
+      inputs.add(new TestInput("input" + i));
+    }
+
+    PCollection<TestInput> inputCollection =
+        pipeline.apply(
+            "CreateInputs", 
Create.of(inputs).withCoder(SerializableCoder.of(TestInput.class)));
+
+    // Configure BatchElements to force a batch of exactly 1 so we get enough 
requests
+    org.apache.beam.sdk.transforms.BatchElements.BatchConfig batchConfig =
+        org.apache.beam.sdk.transforms.BatchElements.BatchConfig.builder()
+            .withMinBatchSize(1)
+            .withMaxBatchSize(1)
+            .build();
+
+    PCollection<Iterable<PredictionResult<TestInput, TestOutput>>> results =
+        inputCollection.apply(
+            "RemoteInference",
+            RemoteInference.<TestInput, TestOutput>invoke()
+                .handler(MockThrottlingHandler.class)
+                .withBatchConfig(batchConfig)
+                // Use large sample periods so the 1s retry delay doesn't 
flush the history
+                .withSamplePeriodMs(60000L)
+                .withSampleUpdateMs(60000L)
+                // Set to 1 second to minimize test wait time while still 
verifying throttling
+                .withThrottleDelaySecs(1)
+                .withOverloadRatio(1.1)
+                .withParameters(params));
+
+    PAssert.that(results)
+        .satisfies(
+            batches -> {
+              int totalElements = 0;
+              for (Iterable<PredictionResult<TestInput, TestOutput>> batch : 
batches) {
+                totalElements += (int) 
StreamSupport.stream(batch.spliterator(), false).count();
+              }
+              assertEquals("Expected all 30 elements to succeed", 30, 
totalElements);
+              return null;
+            });
+
+    org.apache.beam.sdk.PipelineResult result = pipeline.run();
+    result.waitUntilFinish();
+
+    // Verify that the throttling metrics were populated.
+    // The metric name is defined by Metrics.THROTTLE_TIME_COUNTER_NAME which 
evaluates to
+    // "cumulativeThrottlingSeconds".
+    org.apache.beam.sdk.metrics.MetricQueryResults metrics =
+        result
+            .metrics()
+            .queryMetrics(
+                org.apache.beam.sdk.metrics.MetricsFilter.builder()
+                    .addNameFilter(
+                        org.apache.beam.sdk.metrics.MetricNameFilter.named(
+                            "RemoteInference",
+                            
org.apache.beam.sdk.metrics.Metrics.THROTTLE_TIME_COUNTER_NAME))
+                    .build());
+
+    // Throttling may not trigger if random numbers are very skewed, but with 
30 elements * 2
+    // failures each = 60 failures,
+    // and overloadRatio=1.1, the chance of not throttling at least once is 
very small.
+    // If this test becomes flaky, increase the number of inputs.
+    boolean hasThrottled =
+        StreamSupport.stream(metrics.getCounters().spliterator(), false)
+            .anyMatch(
+                metricResult -> metricResult.getAttempted() > 0 || 
metricResult.getCommitted() > 0);
+
+    assertTrue("Expected client-side throttling to trigger at least once", 
hasThrottled);
+  }
 }

Reply via email to