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

scwhittle 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 fc370c9fdc8 [FnApi Java] Add support for separate named data streams 
to provide bundle isolation (#38863)
fc370c9fdc8 is described below

commit fc370c9fdc8a2e5e5b379149a9210dd19c615396
Author: Sam Whittle <[email protected]>
AuthorDate: Fri Jun 19 16:02:30 2026 +0200

    [FnApi Java] Add support for separate named data streams to provide bundle 
isolation (#38863)
    
    * [FnApi Java] Add support for separate named data streams to provide 
bundle isolation.
    
    This is advertised to the runner via a new NAMED_DATA_STREAMS protocol 
capability. The runner is then free to assign bundles to named data streams as 
it chooses
    to isolate bundle processing from each other. Instead of single data stream
    from the sdk, the sdk will create a data stream for each name.  The benefit 
of
    doing so is that the multiplexing currently performed on data stream 
messages being received allows a slow bundle to fill up buffers and block the 
shared stream.  With separate named streams, bundles on other data streams have 
separate grpc flow control from the blocked stream and are not affected.
---
 .../beam_PostCommit_Java_DataflowV2.json           |   2 +-
 ...ostCommit_Java_ValidatesRunner_Dataflow_V2.json |   2 +-
 ...beam_PostCommit_Java_ValidatesRunner_Spark.json |  18 +--
 CHANGES.md                                         |   1 +
 .../beam/model/fn_execution/v1/beam_fn_api.proto   |  38 +++++-
 .../beam/model/pipeline/v1/beam_runner_api.proto   |   4 +
 .../fnexecution/control/SdkHarnessClient.java      |   2 +-
 .../runners/fnexecution/data/FnDataService.java    |   3 +-
 .../runners/fnexecution/data/GrpcDataService.java  |  13 +-
 .../fnexecution/data/GrpcDataServiceTest.java      |   2 +-
 .../sdk/fn/data/BeamFnDataGrpcMultiplexer.java     |   2 +-
 .../sdk/fn/data/BeamFnDataOutboundAggregator.java  | 131 ++++++++++++++------
 .../beam/sdk/util/construction/Environments.java   |   1 +
 .../fn/data/BeamFnDataOutboundAggregatorTest.java  | 135 ++++++++++++---------
 .../java/org/apache/beam/fn/harness/FnHarness.java |  49 +++++---
 .../fn/harness/control/ProcessBundleHandler.java   |  50 +++++---
 .../beam/fn/harness/data/BeamFnDataClient.java     |  28 ++---
 .../beam/fn/harness/data/BeamFnDataGrpcClient.java |  91 +++++++++-----
 .../beam/fn/harness/BeamFnDataWriteRunnerTest.java |  44 ++++---
 .../beam/fn/harness/FnApiDoFnRunnerTest.java       |   2 +-
 .../PTransformRunnerFactoryTestContext.java        |  12 +-
 .../harness/control/ProcessBundleHandlerTest.java  |  47 +++----
 .../fn/harness/data/BeamFnDataGrpcClientTest.java  |  41 +++----
 23 files changed, 417 insertions(+), 301 deletions(-)

diff --git a/.github/trigger_files/beam_PostCommit_Java_DataflowV2.json 
b/.github/trigger_files/beam_PostCommit_Java_DataflowV2.json
index 73012c45df1..d0d50256983 100644
--- a/.github/trigger_files/beam_PostCommit_Java_DataflowV2.json
+++ b/.github/trigger_files/beam_PostCommit_Java_DataflowV2.json
@@ -1,4 +1,4 @@
 {
-  "modification": 6,
+  "modification": 7,
   "https://github.com/apache/beam/pull/35159": "moving WindowedValue and 
making an interface"
 }
diff --git 
a/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_Dataflow_V2.json 
b/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_Dataflow_V2.json
index 0726e6343b3..22f6c3813b6 100644
--- 
a/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_Dataflow_V2.json
+++ 
b/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_Dataflow_V2.json
@@ -1,4 +1,4 @@
 {
   "comment": "Modify this file in a trivial way to cause this test suite to 
run!",
-  "modification": 4
+  "modification": 5
 }
diff --git 
a/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_Spark.json 
b/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_Spark.json
index f4e3f8b8dbe..1efc8e9e440 100644
--- a/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_Spark.json
+++ b/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_Spark.json
@@ -1,18 +1,4 @@
 {
-  "https://github.com/apache/beam/pull/34902": "Introducing OutputBuilder",
-  "https://github.com/apache/beam/pull/35213": "Eliminating getPane() in favor 
of getPaneInfo()",
-  "https://github.com/apache/beam/pull/35177": "Introducing 
WindowedValueReceiver to runners",
-  "comment": "Modify this file in a trivial way to cause this test suite to 
run",
-  "https://github.com/apache/beam/pull/31156": "noting that PR #31156 should 
run this test",
-  "https://github.com/apache/beam/pull/31798": "noting that PR #31798 should 
run this test",
-  "https://github.com/apache/beam/pull/32546": "noting that PR #32546 should 
run this test",
-  "https://github.com/apache/beam/pull/33267": "noting that PR #33267 should 
run this test",
-  "https://github.com/apache/beam/pull/33322": "noting that PR #33322 should 
run this test",
-  "https://github.com/apache/beam/pull/34123": "noting that PR #34123 should 
run this test",
-  "https://github.com/apache/beam/pull/34080": "noting that PR #34080 should 
run this test",
-  "https://github.com/apache/beam/pull/34155": "noting that PR #34155 should 
run this test",
-  "https://github.com/apache/beam/pull/34560": "noting that PR #34560 should 
run this test",
-  "https://github.com/apache/beam/pull/35159": "moving WindowedValue and 
making an interface",
-  "https://github.com/apache/beam/pull/35316": "noting that PR #35316 should 
run this test",
-  "https://github.com/apache/beam/pull/38011": "noting that PR #38011 should 
run this test"
+    "comment": "Modify this file in a trivial way to cause this test suite to 
run",
+    "modification": 1
 }
diff --git a/CHANGES.md b/CHANGES.md
index eb24a71d0bf..0139b8bbd3b 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -73,6 +73,7 @@
 
 * (Java) Enabled state tag encoding v2 by default for new Dataflow Streaming 
Engine jobs. It can be disabled by passing 
`--experiments=disable_streaming_engine_state_tag_encoding_v2` or 
`--updateCompatibilityVersion=2.74.0` pipeline option. Note that the tag 
encoding version cannot change during a job update. Jobs using tag encoding v2 
(enabled by default for new jobs on 2.75.0+) cannot be downgraded to Beam 
versions prior to 2.73.0, as only versions 2.73.0 and later support tag 
encoding [...]
 * (Python) Added instrumentation to support off-the-shelf profiling agents 
when launching Python SDK Harness 
([#38853](https://github.com/apache/beam/issues/38853)).
+* (Java) Added support to the FnApi Data stream protocol allowing runners to 
isolate bundles slowly processing input from other bundles. 
([#39001](https://github.com/apache/beam/issues/39001)).
 
 ## Breaking Changes
 
diff --git 
a/model/fn-execution/src/main/proto/org/apache/beam/model/fn_execution/v1/beam_fn_api.proto
 
b/model/fn-execution/src/main/proto/org/apache/beam/model/fn_execution/v1/beam_fn_api.proto
index ecef3f2e7a9..80a0fa6f7f2 100644
--- 
a/model/fn-execution/src/main/proto/org/apache/beam/model/fn_execution/v1/beam_fn_api.proto
+++ 
b/model/fn-execution/src/main/proto/org/apache/beam/model/fn_execution/v1/beam_fn_api.proto
@@ -120,6 +120,9 @@ message RemoteGrpcPort {
 service BeamFnControl {
   // Instructions sent by the runner to the SDK requesting different types
   // of work.
+  //
+  // Header metadata has the specified keys pairs:
+  // - "worker_id": the id of the sdk
   rpc Control(
       // A stream of responses to instructions the SDK was asked to be
       // performed.
@@ -130,6 +133,9 @@ service BeamFnControl {
 
   // Used to get the full process bundle descriptors for bundles one
   // is asked to process.
+  //
+  // Header metadata has the specified keys pairs:
+  // - "worker_id": the id of the sdk
   rpc GetProcessBundleDescriptor(GetProcessBundleDescriptorRequest) returns (
       ProcessBundleDescriptor) {}
 }
@@ -416,14 +422,22 @@ message ProcessBundleRequest {
   // at https://s.apache.org/beam-fn-api-control-data-embedding.
   Elements elements = 3;
 
-  // indicates that the runner has no stare for the keys in this bundle
+  // Indicates that the runner has no state for the keys in this bundle
   // so SDk can safely begin stateful processing with a locally-generated
-  // initial empty state
+  // initial empty state.
   bool has_no_state = 4;
 
-  // indicates that the runner will never process another bundle for the keys
+  // Indicates that the runner will never process another bundle for the keys
   // in this bundle so state need not be included in the bundle commit.
   bool only_bundle_for_keys = 5;
+
+  // (Optional) If non-empty, the ID of the data stream to use for all data
+  // requests related to this bundle. See comments at BeamFnData.Data for
+  // more details.
+  //
+  // The runner should only populate this field if the sdk advertises the
+  // beam:protocol:named_data_streams:v1 capability.
+  string data_stream_id = 6;
 }
 
 message ProcessBundleResponse {
@@ -834,7 +848,15 @@ message Elements {
 
 // Stable
 service BeamFnData {
-  // Used to send data between harnesses.
+  // Used to send data between harnesses. Sdks default to using an unnamed 
data stream
+  // (without "data_stream_id" header value) for bundles unless the runner 
requests another named stream to be
+  // used for a bundle. SDKs can advertise that they support named data 
streams with the capability
+  // `beam:protocol:named_data_streams:v1`.
+  //
+  // Header metadata has the specified keys pairs:
+  // - "worker_id": value is the id of the sdk
+  // - "data_stream_id": value is the id of the data stream, distinguishing it 
from other data streams from the same
+  //   sdk. This field should only be populated if requested in a received 
ProcessBundleRequest from the runner.
   rpc Data(
       // A stream of data representing input.
       stream Elements)
@@ -900,6 +922,9 @@ message StateResponse {
 
 service BeamFnState {
   // Used to get/append/clear state stored by the runner on behalf of the SDK.
+  //
+  // Header metadata has the specified keys pairs:
+  // - "worker_id": the id of the sdk
   rpc State(
       // A stream of state instructions requested of the runner.
       stream StateRequest)
@@ -1295,6 +1320,9 @@ message LogControl {}
 service BeamFnLogging {
   // Allows for the SDK to emit log entries which the runner can
   // associate with the active job.
+  //
+  // Header metadata has the specified keys pairs:
+  // - "worker_id": the id of the sdk
   rpc Logging(
       // A stream of log entries batched into lists emitted by the SDK harness.
       stream LogEntry.List)
@@ -1356,6 +1384,8 @@ message WorkerStatusResponse {
 
 // API for SDKs to report debug-related statuses to runner during pipeline 
execution.
 service BeamFnWorkerStatus {
+  // Header metadata has the specified keys pairs:
+  // - "worker_id": the id of the sdk
   rpc WorkerStatus (stream WorkerStatusResponse)
     returns (stream WorkerStatusRequest) {}
 }
diff --git 
a/model/pipeline/src/main/proto/org/apache/beam/model/pipeline/v1/beam_runner_api.proto
 
b/model/pipeline/src/main/proto/org/apache/beam/model/pipeline/v1/beam_runner_api.proto
index 67df8b9e800..5824c9bf4b7 100644
--- 
a/model/pipeline/src/main/proto/org/apache/beam/model/pipeline/v1/beam_runner_api.proto
+++ 
b/model/pipeline/src/main/proto/org/apache/beam/model/pipeline/v1/beam_runner_api.proto
@@ -1689,6 +1689,10 @@ message StandardProtocols {
     // Indicates whether the SDK supports multimap state.
     MULTIMAP_STATE = 12
     [(beam_urn) = "beam:protocol:multimap_state:v1"];
+
+    // Indicates whether the SDK supports data stream ids being requested by 
the runner in
+    // ProcessBundleRequests.
+    NAMED_DATA_STREAMS = 13 [(beam_urn) = 
"beam:protocol:named_data_streams:v1"];
   }
 }
 
diff --git 
a/runners/java-fn-execution/src/main/java/org/apache/beam/runners/fnexecution/control/SdkHarnessClient.java
 
b/runners/java-fn-execution/src/main/java/org/apache/beam/runners/fnexecution/control/SdkHarnessClient.java
index 682c45e3079..704d298a195 100644
--- 
a/runners/java-fn-execution/src/main/java/org/apache/beam/runners/fnexecution/control/SdkHarnessClient.java
+++ 
b/runners/java-fn-execution/src/main/java/org/apache/beam/runners/fnexecution/control/SdkHarnessClient.java
@@ -298,7 +298,7 @@ public class SdkHarnessClient implements AutoCloseable {
       ImmutableMap.Builder<LogicalEndpoint, FnDataReceiver<?>> receiverBuilder 
=
           ImmutableMap.builder();
       BeamFnDataOutboundAggregator beamFnDataOutboundAggregator =
-          fnApiDataService.createOutboundAggregator(() -> bundleId, false);
+          fnApiDataService.createOutboundAggregator(bundleId, false);
       for (RemoteInputDestination remoteInput : remoteInputs) {
         LogicalEndpoint endpoint = LogicalEndpoint.data(bundleId, 
remoteInput.getPTransformId());
         receiverBuilder.put(
diff --git 
a/runners/java-fn-execution/src/main/java/org/apache/beam/runners/fnexecution/data/FnDataService.java
 
b/runners/java-fn-execution/src/main/java/org/apache/beam/runners/fnexecution/data/FnDataService.java
index 7c5f110eab2..657ec74553b 100644
--- 
a/runners/java-fn-execution/src/main/java/org/apache/beam/runners/fnexecution/data/FnDataService.java
+++ 
b/runners/java-fn-execution/src/main/java/org/apache/beam/runners/fnexecution/data/FnDataService.java
@@ -17,7 +17,6 @@
  */
 package org.apache.beam.runners.fnexecution.data;
 
-import java.util.function.Supplier;
 import org.apache.beam.model.fnexecution.v1.BeamFnApi.Elements;
 import org.apache.beam.sdk.fn.data.BeamFnDataOutboundAggregator;
 import org.apache.beam.sdk.fn.data.CloseableFnDataReceiver;
@@ -69,5 +68,5 @@ public interface FnDataService {
    * <p>The returned aggregator is not thread safe.
    */
   BeamFnDataOutboundAggregator createOutboundAggregator(
-      Supplier<String> processBundleRequestIdSupplier, boolean 
collectElementsIfNoFlushes);
+      String processBundleId, boolean collectElementsIfNoFlushes);
 }
diff --git 
a/runners/java-fn-execution/src/main/java/org/apache/beam/runners/fnexecution/data/GrpcDataService.java
 
b/runners/java-fn-execution/src/main/java/org/apache/beam/runners/fnexecution/data/GrpcDataService.java
index d4e45c8ccf8..a3a8c324404 100644
--- 
a/runners/java-fn-execution/src/main/java/org/apache/beam/runners/fnexecution/data/GrpcDataService.java
+++ 
b/runners/java-fn-execution/src/main/java/org/apache/beam/runners/fnexecution/data/GrpcDataService.java
@@ -23,7 +23,6 @@ import java.util.concurrent.ExecutorService;
 import java.util.concurrent.LinkedBlockingQueue;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
-import java.util.function.Supplier;
 import org.apache.beam.model.fnexecution.v1.BeamFnApi;
 import org.apache.beam.model.fnexecution.v1.BeamFnApi.Elements;
 import org.apache.beam.model.fnexecution.v1.BeamFnDataGrpc;
@@ -175,13 +174,13 @@ public class GrpcDataService extends 
BeamFnDataGrpc.BeamFnDataImplBase
 
   @Override
   public BeamFnDataOutboundAggregator createOutboundAggregator(
-      Supplier<String> processBundleRequestIdSupplier, boolean 
collectElementsIfNoFlushes) {
+      String instructionId, boolean collectElementsIfNoFlushes) {
     try {
-      return new BeamFnDataOutboundAggregator(
-          options,
-          processBundleRequestIdSupplier,
-          connectedClient.get(3, TimeUnit.MINUTES).getOutboundObserver(),
-          collectElementsIfNoFlushes);
+      BeamFnDataOutboundAggregator aggregator =
+          new BeamFnDataOutboundAggregator(options, 
collectElementsIfNoFlushes);
+      aggregator.prepareForInstruction(
+          instructionId, connectedClient.get(3, 
TimeUnit.MINUTES).getOutboundObserver());
+      return aggregator;
     } catch (InterruptedException e) {
       Thread.currentThread().interrupt();
       throw new RuntimeException(e);
diff --git 
a/runners/java-fn-execution/src/test/java/org/apache/beam/runners/fnexecution/data/GrpcDataServiceTest.java
 
b/runners/java-fn-execution/src/test/java/org/apache/beam/runners/fnexecution/data/GrpcDataServiceTest.java
index f8446707750..363367f1087 100644
--- 
a/runners/java-fn-execution/src/test/java/org/apache/beam/runners/fnexecution/data/GrpcDataServiceTest.java
+++ 
b/runners/java-fn-execution/src/test/java/org/apache/beam/runners/fnexecution/data/GrpcDataServiceTest.java
@@ -102,7 +102,7 @@ public class GrpcDataServiceTest {
       for (int i = 0; i < 3; ++i) {
         final String instructionId = Integer.toString(i);
         BeamFnDataOutboundAggregator aggregator =
-            service.createOutboundAggregator(() -> instructionId, false);
+            service.createOutboundAggregator(instructionId, false);
         aggregator.start();
         FnDataReceiver<WindowedValue<String>> consumer =
             aggregator.registerOutputDataLocation(TRANSFORM_ID, CODER);
diff --git 
a/sdks/java/core/src/main/java/org/apache/beam/sdk/fn/data/BeamFnDataGrpcMultiplexer.java
 
b/sdks/java/core/src/main/java/org/apache/beam/sdk/fn/data/BeamFnDataGrpcMultiplexer.java
index 8fec8b455cc..0b9d6adab4f 100644
--- 
a/sdks/java/core/src/main/java/org/apache/beam/sdk/fn/data/BeamFnDataGrpcMultiplexer.java
+++ 
b/sdks/java/core/src/main/java/org/apache/beam/sdk/fn/data/BeamFnDataGrpcMultiplexer.java
@@ -63,7 +63,7 @@ public class BeamFnDataGrpcMultiplexer implements 
AutoCloseable {
   private final Cache</*instructionId=*/ String, /*unused=*/ Boolean> 
poisonedInstructionIds;
 
   private static class PoisonedException extends RuntimeException {
-    public PoisonedException() {
+    private PoisonedException() {
       super("Instruction poisoned");
     }
   };
diff --git 
a/sdks/java/core/src/main/java/org/apache/beam/sdk/fn/data/BeamFnDataOutboundAggregator.java
 
b/sdks/java/core/src/main/java/org/apache/beam/sdk/fn/data/BeamFnDataOutboundAggregator.java
index 9b9603706b4..04c7e6ef96b 100644
--- 
a/sdks/java/core/src/main/java/org/apache/beam/sdk/fn/data/BeamFnDataOutboundAggregator.java
+++ 
b/sdks/java/core/src/main/java/org/apache/beam/sdk/fn/data/BeamFnDataOutboundAggregator.java
@@ -17,6 +17,9 @@
  */
 package org.apache.beam.sdk.fn.data;
 
+import static 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkNotNull;
+import static 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkState;
+
 import java.io.IOException;
 import java.util.Collections;
 import java.util.HashMap;
@@ -28,7 +31,6 @@ import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Executors;
 import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.TimeUnit;
-import java.util.function.Supplier;
 import javax.annotation.Nullable;
 import javax.annotation.concurrent.NotThreadSafe;
 import org.apache.beam.model.fnexecution.v1.BeamFnApi.Elements;
@@ -53,10 +55,7 @@ import org.slf4j.LoggerFactory;
  * <p>The default time-based buffer threshold can be overridden by specifying 
the experiment {@code
  * data_buffer_time_limit_ms=<milliseconds>}
  */
-@SuppressWarnings({
-  "nullness" // TODO(https://github.com/apache/beam/issues/20497)
-})
-// The calling thread that invokes sendBufferedDataAndFinishOutboundStreams 
synchronizes on
+// The calling thread that invokes 
sendOrCollectBufferedDataAndFinishOutboundStreams synchronizes on
 // flushLock effectively making the periodic flushing no longer read or mutate 
hasFlushedForBundle
 // and allowing the calling thread to read and mutate hasFlushedForBundle 
safely without needing to
 // create another memory barrier. Also note that flush is always invoked when 
synchronizing on
@@ -72,31 +71,54 @@ public class BeamFnDataOutboundAggregator {
   private static final Logger LOG = 
LoggerFactory.getLogger(BeamFnDataOutboundAggregator.class);
   private final int sizeLimit;
   private final long timeLimit;
-  private final Supplier<String> processBundleRequestIdSupplier;
-  @VisibleForTesting final Map<String, Receiver<?>> outputDataReceivers;
-  @VisibleForTesting final Map<TimerEndpoint, Receiver<?>> 
outputTimersReceivers;
-  private final StreamObserver<Elements> outboundObserver;
+  // The instructionId is set between prepareForInstruction and 
finishInstruction/discard.
+  private @Nullable String instructionId = null;
+  @VisibleForTesting final Map<String, Receiver<?>> outputDataReceivers = new 
HashMap<>();
+  @VisibleForTesting final Map<TimerEndpoint, Receiver<?>> 
outputTimersReceivers = new HashMap<>();
+  @Nullable private StreamObserver<Elements> outboundObserver;
   @Nullable @VisibleForTesting ScheduledFuture<?> flushFuture;
-  private long bytesWrittenSinceFlush;
-  private final Object flushLock;
+  private long bytesWrittenSinceFlush = 0;
+  private final Object flushLock = new Object();
   private final boolean collectElementsIfNoFlushes;
-  private boolean hasFlushedForBundle;
+  private boolean hasFlushedForBundle = false;
 
-  public BeamFnDataOutboundAggregator(
-      PipelineOptions options,
-      Supplier<String> processBundleRequestIdSupplier,
-      StreamObserver<Elements> outboundObserver,
-      boolean collectElementsIfNoFlushes) {
+  public BeamFnDataOutboundAggregator(PipelineOptions options, boolean 
collectElementsIfNoFlushes) {
     this.sizeLimit = getSizeLimit(options);
     this.timeLimit = getTimeLimit(options);
     this.collectElementsIfNoFlushes = collectElementsIfNoFlushes;
-    this.outputDataReceivers = new HashMap<>();
-    this.outputTimersReceivers = new HashMap<>();
-    this.outboundObserver = outboundObserver;
-    this.processBundleRequestIdSupplier = processBundleRequestIdSupplier;
-    this.bytesWrittenSinceFlush = 0L;
-    this.flushLock = new Object();
-    this.hasFlushedForBundle = false;
+  }
+
+  public void prepareForInstruction(
+      String instructionId, StreamObserver<Elements> outboundObserver) {
+    if (timeLimit > 0) {
+      synchronized (flushLock) {
+        checkState(this.instructionId == null && this.outboundObserver == 
null);
+        this.instructionId = instructionId;
+        this.outboundObserver = outboundObserver;
+      }
+    } else {
+      checkState(this.instructionId == null && this.outboundObserver == null);
+      this.instructionId = instructionId;
+      this.outboundObserver = outboundObserver;
+    }
+  }
+
+  public void finishInstruction() {
+    if (flushFuture != null) {
+      synchronized (flushLock) {
+        checkState(
+            this.instructionId != null && this.outboundObserver != null,
+            "instruction was not started or previously completed");
+        checkState(bytesWrittenSinceFlush == 0, "bytes were not flushed for 
instruction");
+        this.instructionId = null;
+        this.outboundObserver = null;
+      }
+    } else {
+      checkState(this.instructionId != null && this.outboundObserver != null);
+      checkState(bytesWrittenSinceFlush == 0, "bytes were not flushed for 
instruction");
+      this.instructionId = null;
+      this.outboundObserver = null;
+    }
   }
 
   /** Starts the flushing daemon thread if data_buffer_time_limit_ms is set. */
@@ -166,7 +188,7 @@ public class BeamFnDataOutboundAggregator {
     }
     Elements.Builder elements = convertBufferForTransmission();
     if (elements.getDataCount() > 0 || elements.getTimersCount() > 0) {
-      outboundObserver.onNext(elements.build());
+      checkNotNull(outboundObserver).onNext(elements.build());
     }
     hasFlushedForBundle = true;
   }
@@ -177,10 +199,15 @@ public class BeamFnDataOutboundAggregator {
    * collectElementsIfNoFlushes=true, and there was no previous flush in this 
bundle, otherwise
    * returns null.
    */
+  @Nullable
   public Elements sendOrCollectBufferedDataAndFinishOutboundStreams() {
     if (outputTimersReceivers.isEmpty() && outputDataReceivers.isEmpty()) {
       return null;
     }
+    String instructionId =
+        checkNotNull(
+            this.instructionId,
+            "This method should only be called between prepareForInstruction 
and finishInstruction");
     Elements.Builder bufferedElements;
     if (timeLimit > 0) {
       synchronized (flushLock) {
@@ -191,14 +218,14 @@ public class BeamFnDataOutboundAggregator {
     }
     LOG.debug(
         "Closing streams for instruction {} and outbound data {} and timers 
{}.",
-        processBundleRequestIdSupplier.get(),
+        instructionId,
         outputDataReceivers,
         outputTimersReceivers);
     for (Map.Entry<String, Receiver<?>> entry : 
outputDataReceivers.entrySet()) {
       String pTransformId = entry.getKey();
       bufferedElements
           .addDataBuilder()
-          .setInstructionId(processBundleRequestIdSupplier.get())
+          .setInstructionId(instructionId)
           .setTransformId(pTransformId)
           .setIsLast(true);
       entry.getValue().resetStats();
@@ -207,35 +234,60 @@ public class BeamFnDataOutboundAggregator {
       TimerEndpoint timerKey = entry.getKey();
       bufferedElements
           .addTimersBuilder()
-          .setInstructionId(processBundleRequestIdSupplier.get())
+          .setInstructionId(instructionId)
           .setTransformId(timerKey.pTransformId)
           .setTimerFamilyId(timerKey.timerFamilyId)
           .setIsLast(true);
       entry.getValue().resetStats();
     }
+    // This is the end of the bundle so we reset state to prepare for future 
bundles.
     if (collectElementsIfNoFlushes && !hasFlushedForBundle) {
       return bufferedElements.build();
     }
-    outboundObserver.onNext(bufferedElements.build());
-    // This is now at the end of a bundle, so we reset hasFlushedForBundle to 
prepare for new
-    // bundles.
+    checkNotNull(outboundObserver).onNext(bufferedElements.build());
     hasFlushedForBundle = false;
     return null;
   }
 
   // Send the elements to the StreamObserver associated with this aggregator.
   public void sendElements(Elements elements) {
-    outboundObserver.onNext(elements);
+    if (timeLimit > 0) {
+      synchronized (flushLock) {
+        checkNotNull(outboundObserver).onNext(elements);
+      }
+    } else {
+      checkNotNull(outboundObserver).onNext(elements);
+    }
   }
 
+  // Prepares for discarding the aggregator without preserving its output or
+  // preparing it for reuse.
   public void discard() {
-    if (flushFuture != null) {
-      flushFuture.cancel(true);
+    if (timeLimit > 0) {
+      // Short-circuit the possibly concurrently running flush.
+      synchronized (flushLock) {
+        bytesWrittenSinceFlush = 0L;
+        finishInstruction();
+      }
+      if (flushFuture != null) {
+        flushFuture.cancel(false);
+      }
+    } else {
+      bytesWrittenSinceFlush = 0L;
+      finishInstruction();
     }
   }
 
   private Elements.Builder convertBufferForTransmission() {
     Elements.Builder bufferedElements = Elements.newBuilder();
+    if (bytesWrittenSinceFlush == 0) {
+      return bufferedElements;
+    }
+    bytesWrittenSinceFlush = 0L;
+    String instructionId =
+        checkNotNull(
+            this.instructionId,
+            "This method should only be called between prepareForInstruction 
and finishInstruction");
     for (Map.Entry<String, Receiver<?>> entry : 
outputDataReceivers.entrySet()) {
       if (!entry.getValue().hasBufferedOutput()) {
         continue;
@@ -243,7 +295,7 @@ public class BeamFnDataOutboundAggregator {
       ByteString bytes = entry.getValue().toByteStringAndResetBuffer();
       bufferedElements
           .addDataBuilder()
-          .setInstructionId(processBundleRequestIdSupplier.get())
+          .setInstructionId(instructionId)
           .setTransformId(entry.getKey())
           .setData(bytes);
     }
@@ -254,12 +306,11 @@ public class BeamFnDataOutboundAggregator {
       ByteString bytes = entry.getValue().toByteStringAndResetBuffer();
       bufferedElements
           .addTimersBuilder()
-          .setInstructionId(processBundleRequestIdSupplier.get())
+          .setInstructionId(instructionId)
           .setTransformId(entry.getKey().pTransformId)
           .setTimerFamilyId(entry.getKey().timerFamilyId)
           .setTimers(bytes);
     }
-    bytesWrittenSinceFlush = 0L;
     return bufferedElements;
   }
 
@@ -277,7 +328,7 @@ public class BeamFnDataOutboundAggregator {
 
   /** Check if the flush thread failed with an exception. */
   private void checkFlushThreadException() throws IOException {
-    if (timeLimit > 0 && flushFuture.isDone()) {
+    if (flushFuture != null && flushFuture.isDone()) {
       try {
         flushFuture.get();
         throw new IOException("Periodic flushing thread finished 
unexpectedly.");
@@ -353,10 +404,12 @@ public class BeamFnDataOutboundAggregator {
       }
     }
 
+    @VisibleForTesting
     public long getByteCount() {
       return perBundleByteCount;
     }
 
+    @VisibleForTesting
     public long getElementCount() {
       return perBundleElementCount;
     }
@@ -392,7 +445,7 @@ public class BeamFnDataOutboundAggregator {
     }
 
     @Override
-    public boolean equals(Object o) {
+    public boolean equals(@Nullable Object o) {
       if (this == o) {
         return true;
       }
diff --git 
a/sdks/java/core/src/main/java/org/apache/beam/sdk/util/construction/Environments.java
 
b/sdks/java/core/src/main/java/org/apache/beam/sdk/util/construction/Environments.java
index 969bda88d07..c3b1a7a5235 100644
--- 
a/sdks/java/core/src/main/java/org/apache/beam/sdk/util/construction/Environments.java
+++ 
b/sdks/java/core/src/main/java/org/apache/beam/sdk/util/construction/Environments.java
@@ -522,6 +522,7 @@ public class Environments {
     
capabilities.add(BeamUrns.getUrn(StandardProtocols.Enum.SDK_CONSUMING_RECEIVED_DATA));
     
capabilities.add(BeamUrns.getUrn(StandardProtocols.Enum.ORDERED_LIST_STATE));
     capabilities.add(BeamUrns.getUrn(StandardProtocols.Enum.MULTIMAP_STATE));
+    
capabilities.add(BeamUrns.getUrn(StandardProtocols.Enum.NAMED_DATA_STREAMS));
     return capabilities.build();
   }
 
diff --git 
a/sdks/java/core/src/test/java/org/apache/beam/sdk/fn/data/BeamFnDataOutboundAggregatorTest.java
 
b/sdks/java/core/src/test/java/org/apache/beam/sdk/fn/data/BeamFnDataOutboundAggregatorTest.java
index 092ba200c94..9bcf615d638 100644
--- 
a/sdks/java/core/src/test/java/org/apache/beam/sdk/fn/data/BeamFnDataOutboundAggregatorTest.java
+++ 
b/sdks/java/core/src/test/java/org/apache/beam/sdk/fn/data/BeamFnDataOutboundAggregatorTest.java
@@ -20,6 +20,7 @@ package org.apache.beam.sdk.fn.data;
 import static org.hamcrest.Matchers.empty;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.fail;
 
 import java.io.IOException;
@@ -75,13 +76,12 @@ public class BeamFnDataOutboundAggregatorTest {
     final List<Elements> values = new ArrayList<>();
     final AtomicBoolean onCompletedWasCalled = new AtomicBoolean();
     BeamFnDataOutboundAggregator aggregator =
-        new BeamFnDataOutboundAggregator(
-            PipelineOptionsFactory.create(),
-            endpoint::getInstructionId,
-            TestStreams.<Elements>withOnNext(values::add)
-                .withOnCompleted(() -> onCompletedWasCalled.set(true))
-                .build(),
-            false);
+        new BeamFnDataOutboundAggregator(PipelineOptionsFactory.create(), 
false);
+    aggregator.prepareForInstruction(
+        endpoint.getInstructionId(),
+        TestStreams.<Elements>withOnNext(values::add)
+            .withOnCompleted(() -> onCompletedWasCalled.set(true))
+            .build());
 
     // Test that nothing is emitted till the default buffer size is surpassed.
     FnDataReceiver<byte[]> dataReceiver = registerOutputLocation(aggregator, 
endpoint, CODER);
@@ -124,14 +124,12 @@ public class BeamFnDataOutboundAggregatorTest {
     options
         .as(ExperimentalOptions.class)
         .setExperiments(Arrays.asList("data_buffer_size_limit=100"));
-    BeamFnDataOutboundAggregator aggregator =
-        new BeamFnDataOutboundAggregator(
-            options,
-            endpoint::getInstructionId,
-            TestStreams.<Elements>withOnNext(values::add)
-                .withOnCompleted(() -> onCompletedWasCalled.set(true))
-                .build(),
-            false);
+    BeamFnDataOutboundAggregator aggregator = new 
BeamFnDataOutboundAggregator(options, false);
+    aggregator.prepareForInstruction(
+        endpoint.getInstructionId(),
+        TestStreams.<Elements>withOnNext(values::add)
+            .withOnCompleted(() -> onCompletedWasCalled.set(true))
+            .build());
     // Test that nothing is emitted till the default buffer size is surpassed.
     FnDataReceiver<byte[]> dataReceiver = registerOutputLocation(aggregator, 
endpoint, CODER);
     aggregator.start();
@@ -187,18 +185,16 @@ public class BeamFnDataOutboundAggregatorTest {
         .as(ExperimentalOptions.class)
         .setExperiments(Arrays.asList("data_buffer_time_limit_ms=1"));
     final CountDownLatch waitForFlush = new CountDownLatch(1);
-    BeamFnDataOutboundAggregator aggregator =
-        new BeamFnDataOutboundAggregator(
-            options,
-            endpoint::getInstructionId,
-            TestStreams.withOnNext(
-                    (Consumer<Elements>)
-                        e -> {
-                          values.add(e);
-                          waitForFlush.countDown();
-                        })
-                .build(),
-            false);
+    BeamFnDataOutboundAggregator aggregator = new 
BeamFnDataOutboundAggregator(options, false);
+    aggregator.prepareForInstruction(
+        endpoint.getInstructionId(),
+        TestStreams.withOnNext(
+                (Consumer<Elements>)
+                    e -> {
+                      values.add(e);
+                      waitForFlush.countDown();
+                    })
+            .build());
 
     // Test that it emits when time passed the time limit
     FnDataReceiver<byte[]> dataReceiver = registerOutputLocation(aggregator, 
endpoint, CODER);
@@ -214,17 +210,15 @@ public class BeamFnDataOutboundAggregatorTest {
     options
         .as(ExperimentalOptions.class)
         .setExperiments(Arrays.asList("data_buffer_time_limit_ms=1"));
-    BeamFnDataOutboundAggregator aggregator =
-        new BeamFnDataOutboundAggregator(
-            options,
-            endpoint::getInstructionId,
-            TestStreams.withOnNext(
-                    (Consumer<Elements>)
-                        e -> {
-                          throw new RuntimeException("");
-                        })
-                .build(),
-            false);
+    BeamFnDataOutboundAggregator aggregator = new 
BeamFnDataOutboundAggregator(options, false);
+    aggregator.prepareForInstruction(
+        endpoint.getInstructionId(),
+        TestStreams.withOnNext(
+                (Consumer<Elements>)
+                    e -> {
+                      throw new RuntimeException("");
+                    })
+            .build());
 
     // Test that it emits when time passed the time limit
     FnDataReceiver<byte[]> dataReceiver = registerOutputLocation(aggregator, 
endpoint, CODER);
@@ -243,17 +237,15 @@ public class BeamFnDataOutboundAggregatorTest {
       // expected
     }
 
-    aggregator =
-        new BeamFnDataOutboundAggregator(
-            options,
-            endpoint::getInstructionId,
-            TestStreams.withOnNext(
-                    (Consumer<Elements>)
-                        e -> {
-                          throw new RuntimeException("");
-                        })
-                .build(),
-            false);
+    aggregator = new BeamFnDataOutboundAggregator(options, false);
+    aggregator.prepareForInstruction(
+        endpoint.getInstructionId(),
+        TestStreams.withOnNext(
+                (Consumer<Elements>)
+                    e -> {
+                      throw new RuntimeException("");
+                    })
+            .build());
     dataReceiver = registerOutputLocation(aggregator, endpoint, CODER);
     aggregator.start();
     dataReceiver.accept(new byte[1]);
@@ -279,14 +271,12 @@ public class BeamFnDataOutboundAggregatorTest {
     options
         .as(ExperimentalOptions.class)
         .setExperiments(Arrays.asList("data_buffer_size_limit=100"));
-    BeamFnDataOutboundAggregator aggregator =
-        new BeamFnDataOutboundAggregator(
-            options,
-            endpoint::getInstructionId,
-            TestStreams.<Elements>withOnNext(values::add)
-                .withOnCompleted(() -> onCompletedWasCalled.set(true))
-                .build(),
-            false);
+    BeamFnDataOutboundAggregator aggregator = new 
BeamFnDataOutboundAggregator(options, false);
+    aggregator.prepareForInstruction(
+        endpoint.getInstructionId(),
+        TestStreams.<Elements>withOnNext(values::add)
+            .withOnCompleted(() -> onCompletedWasCalled.set(true))
+            .build());
     // Test that nothing is emitted till the default buffer size is surpassed.
     LogicalEndpoint additionalEndpoint =
         LogicalEndpoint.data(
@@ -334,6 +324,37 @@ public class BeamFnDataOutboundAggregatorTest {
     checkEqualInAnyOrder(builder.build(), values.get(1));
   }
 
+  @Test
+  public void testInstructionLifecycle() {
+    BeamFnDataOutboundAggregator aggregator =
+        new BeamFnDataOutboundAggregator(PipelineOptionsFactory.create(), 
false);
+    assertThrows(
+        NullPointerException.class, () -> 
aggregator.sendElements(Elements.getDefaultInstance()));
+    aggregator.prepareForInstruction(
+        "testInstruction",
+        TestStreams.withOnNext(
+                (Consumer<Elements>)
+                    e -> {
+                      throw new RuntimeException("");
+                    })
+            .build());
+    assertThrows(
+        IllegalStateException.class,
+        () ->
+            aggregator.prepareForInstruction(
+                "testInstruction",
+                TestStreams.withOnNext(
+                        (Consumer<Elements>)
+                            e -> {
+                              throw new RuntimeException("");
+                            })
+                    .build()));
+    aggregator.finishInstruction();
+    assertThrows(
+        NullPointerException.class, () -> 
aggregator.sendElements(Elements.getDefaultInstance()));
+    assertThrows(IllegalStateException.class, aggregator::finishInstruction);
+  }
+
   private void checkEqualInAnyOrder(Elements first, Elements second) {
     MatcherAssert.assertThat(
         first.getDataList(), 
Matchers.containsInAnyOrder(second.getDataList().toArray()));
diff --git 
a/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/FnHarness.java 
b/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/FnHarness.java
index 703e726739a..60e83251f14 100644
--- a/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/FnHarness.java
+++ b/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/FnHarness.java
@@ -17,6 +17,8 @@
  */
 package org.apache.beam.fn.harness;
 
+import static 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkNotNull;
+
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import java.nio.charset.StandardCharsets;
@@ -30,7 +32,6 @@ import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
 import java.util.function.Function;
-import javax.annotation.Nullable;
 import org.apache.beam.fn.harness.control.BeamFnControlClient;
 import org.apache.beam.fn.harness.control.ExecutionStateSampler;
 import org.apache.beam.fn.harness.control.FinalizeBundleHandler;
@@ -72,6 +73,7 @@ import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.annotations.Vi
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableList;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableSet;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.util.concurrent.MoreExecutors;
+import org.checkerframework.checker.nullness.qual.Nullable;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -92,9 +94,6 @@ import org.slf4j.LoggerFactory;
  *       for further details.
  * </ul>
  */
-@SuppressWarnings({
-  "nullness" // TODO(https://github.com/apache/beam/issues/20497)
-})
 public class FnHarness {
   private static final String HARNESS_ID = "HARNESS_ID";
   private static final String CONTROL_API_SERVICE_DESCRIPTOR = 
"CONTROL_API_SERVICE_DESCRIPTOR";
@@ -138,22 +137,31 @@ public class FnHarness {
   }
 
   public static void main(String[] args) throws Exception {
-    main(System::getenv);
+    Function<String, @Nullable String> environmentVarGetter = System::getenv;
+    main(environmentVarGetter);
   }
 
   @VisibleForTesting
-  public static void main(Function<String, String> environmentVarGetter) 
throws Exception {
+  public static void main(Function<String, @Nullable String> 
environmentVarGetter)
+      throws Exception {
     JvmInitializers.runOnStartup();
 
     Endpoints.ApiServiceDescriptor loggingApiServiceDescriptor =
-        
getApiServiceDescriptor(environmentVarGetter.apply(LOGGING_API_SERVICE_DESCRIPTOR));
+        getApiServiceDescriptor(
+            checkNotNull(
+                environmentVarGetter.apply(LOGGING_API_SERVICE_DESCRIPTOR),
+                "LOGGING_API_SERVICE_DESCRIPTOR env var must be set."));
     Endpoints.ApiServiceDescriptor controlApiServiceDescriptor =
-        
getApiServiceDescriptor(environmentVarGetter.apply(CONTROL_API_SERVICE_DESCRIPTOR));
-    Endpoints.ApiServiceDescriptor statusApiServiceDescriptor =
-        environmentVarGetter.apply(STATUS_API_SERVICE_DESCRIPTOR) == null
-            ? null
-            : 
getApiServiceDescriptor(environmentVarGetter.apply(STATUS_API_SERVICE_DESCRIPTOR));
-    String id = environmentVarGetter.apply(HARNESS_ID);
+        getApiServiceDescriptor(
+            checkNotNull(
+                environmentVarGetter.apply(CONTROL_API_SERVICE_DESCRIPTOR),
+                "CONTROL_API_SERVICE_DESCRIPTOR env var must be set."));
+
+    @Nullable String envVar = 
environmentVarGetter.apply(STATUS_API_SERVICE_DESCRIPTOR);
+    Endpoints.@Nullable ApiServiceDescriptor statusApiServiceDescriptor =
+        (envVar == null) ? null : getApiServiceDescriptor(envVar);
+    String id =
+        checkNotNull(environmentVarGetter.apply(HARNESS_ID), "HARNESS_ID env 
var must be set.");
 
     System.out.format("SDK Fn Harness started%n");
     System.out.format("Harness ID %s%n", id);
@@ -161,11 +169,11 @@ public class FnHarness {
     System.out.format("Control location %s%n", controlApiServiceDescriptor);
     System.out.format("Status location %s%n", statusApiServiceDescriptor);
 
-    String pipelineOptionsJson = environmentVarGetter.apply(PIPELINE_OPTIONS);
     // Try looking for a file first. If that exists it should override 
PIPELINE_OPTIONS to avoid
     // maxing out the kernel's environment space
+    @Nullable String pipelineOptionsJson = null;
     try {
-      String pipelineOptionsPath = 
environmentVarGetter.apply(PIPELINE_OPTIONS_FILE);
+      @Nullable String pipelineOptionsPath = 
environmentVarGetter.apply(PIPELINE_OPTIONS_FILE);
       System.out.format("Pipeline Options File %s%n", pipelineOptionsPath);
       if (pipelineOptionsPath != null) {
         Path filePath = Paths.get(pipelineOptionsPath);
@@ -179,11 +187,12 @@ public class FnHarness {
     } catch (Exception e) {
       System.out.format("Problem loading pipeline options from file: %s%n", 
e.getMessage());
     }
-
+    if (pipelineOptionsJson == null) {
+      pipelineOptionsJson = 
checkNotNull(environmentVarGetter.apply(PIPELINE_OPTIONS));
+    }
     System.out.format("Pipeline options %s%n", pipelineOptionsJson);
     // TODO: https://github.com/apache/beam/issues/30301
     pipelineOptionsJson = removeNestedKey(pipelineOptionsJson, 
"impersonateServiceAccount");
-
     PipelineOptions options = 
PipelineOptionsTranslation.fromJson(pipelineOptionsJson);
 
     String runnerCapabilitesOrNull = 
environmentVarGetter.apply(RUNNER_CAPABILITIES);
@@ -219,7 +228,7 @@ public class FnHarness {
       Set<String> runnerCapabilities,
       Endpoints.ApiServiceDescriptor loggingApiServiceDescriptor,
       Endpoints.ApiServiceDescriptor controlApiServiceDescriptor,
-      @Nullable Endpoints.ApiServiceDescriptor statusApiServiceDescriptor)
+      Endpoints.@Nullable ApiServiceDescriptor statusApiServiceDescriptor)
       throws Exception {
     ManagedChannelFactory channelFactory;
     if (ExperimentalOptions.hasExperiment(options, "beam_fn_api_epoll")) {
@@ -263,7 +272,7 @@ public class FnHarness {
       Set<String> runnerCapabilites,
       Endpoints.ApiServiceDescriptor loggingApiServiceDescriptor,
       Endpoints.ApiServiceDescriptor controlApiServiceDescriptor,
-      Endpoints.ApiServiceDescriptor statusApiServiceDescriptor,
+      Endpoints.@Nullable ApiServiceDescriptor statusApiServiceDescriptor,
       ManagedChannelFactory channelFactory,
       OutboundObserverFactory outboundObserverFactory,
       Cache<Object, Object> processWideCache)
@@ -318,7 +327,7 @@ public class FnHarness {
           BeamFnControlGrpc.newBlockingStub(channel);
 
       BeamFnDataGrpcClient beamFnDataMultiplexer =
-          new BeamFnDataGrpcClient(options, channelFactory::forDescriptor, 
outboundObserverFactory);
+          new BeamFnDataGrpcClient(channelFactory::forDescriptor, 
outboundObserverFactory);
 
       BeamFnStateGrpcClientCache beamFnStateGrpcClientCache =
           new BeamFnStateGrpcClientCache(idGenerator, channelFactory, 
outboundObserverFactory);
diff --git 
a/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/control/ProcessBundleHandler.java
 
b/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/control/ProcessBundleHandler.java
index 5a57b137bf6..449afd6a024 100644
--- 
a/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/control/ProcessBundleHandler.java
+++ 
b/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/control/ProcessBundleHandler.java
@@ -224,7 +224,6 @@ public class ProcessBundleHandler {
 
   private void addRunnerAndConsumersForPTransformRecursively(
       BeamFnStateClient beamFnStateClient,
-      BeamFnDataClient queueingClient,
       String pTransformId,
       PTransform pTransform,
       Supplier<String> processBundleInstructionId,
@@ -257,7 +256,6 @@ public class ProcessBundleHandler {
       for (String consumingPTransformId : 
pCollectionIdsToConsumingPTransforms.get(pCollectionId)) {
         addRunnerAndConsumersForPTransformRecursively(
             beamFnStateClient,
-            queueingClient,
             consumingPTransformId,
             
processBundleDescriptor.getTransformsMap().get(consumingPTransformId),
             processBundleInstructionId,
@@ -315,7 +313,7 @@ public class ProcessBundleHandler {
 
                 @Override
                 public BeamFnDataClient getBeamFnDataClient() {
-                  return queueingClient;
+                  return beamFnDataClient;
                 }
 
                 @Override
@@ -378,9 +376,8 @@ public class ProcessBundleHandler {
                       outboundAggregatorMap.computeIfAbsent(
                           apiServiceDescriptor,
                           asd ->
-                              queueingClient.createOutboundAggregator(
-                                  asd,
-                                  processBundleInstructionId,
+                              new BeamFnDataOutboundAggregator(
+                                  options,
                                   runnerCapabilities.contains(
                                       BeamUrns.getUrn(
                                           StandardRunnerProtocols.Enum
@@ -391,21 +388,19 @@ public class ProcessBundleHandler {
                 @Override
                 public <T> FnDataReceiver<Timer<T>> addOutgoingTimersEndpoint(
                     String timerFamilyId, 
org.apache.beam.sdk.coders.Coder<Timer<T>> coder) {
-                  BeamFnDataOutboundAggregator aggregator;
                   if (!processBundleDescriptor.hasTimerApiServiceDescriptor()) 
{
                     throw new IllegalStateException(
                         String.format(
-                            "Timers are unsupported because the "
-                                + "ProcessBundleRequest %s does not provide a 
timer ApiServiceDescriptor.",
+                            "Timers are unsupported because the 
ProcessBundleRequest %s does not"
+                                + " provide a timer ApiServiceDescriptor.",
                             processBundleInstructionId.get()));
                   }
-                  aggregator =
+                  BeamFnDataOutboundAggregator aggregator =
                       outboundAggregatorMap.computeIfAbsent(
                           
processBundleDescriptor.getTimerApiServiceDescriptor(),
                           asd ->
-                              queueingClient.createOutboundAggregator(
-                                  asd,
-                                  processBundleInstructionId,
+                              new BeamFnDataOutboundAggregator(
+                                  options,
                                   runnerCapabilities.contains(
                                       BeamUrns.getUrn(
                                           StandardRunnerProtocols.Enum
@@ -499,6 +494,8 @@ public class ProcessBundleHandler {
    */
   public BeamFnApi.InstructionResponse.Builder 
processBundle(InstructionRequest request)
       throws Exception {
+    String instructionId = request.getInstructionId();
+    String dataStreamId = request.getProcessBundle().getDataStreamId();
     @Nullable BundleProcessor bundleProcessor = null;
     try {
       bundleProcessor =
@@ -515,13 +512,20 @@ public class ProcessBundleHandler {
                     }
                   }));
 
+      for (Map.Entry<ApiServiceDescriptor, BeamFnDataOutboundAggregator> entry 
:
+          bundleProcessor.getOutboundAggregators().entrySet()) {
+        BeamFnDataOutboundAggregator aggregator = entry.getValue();
+        aggregator.prepareForInstruction(
+            instructionId, 
beamFnDataClient.getOutboundObserver(entry.getKey(), dataStreamId));
+      }
+
       PTransformFunctionRegistry startFunctionRegistry = 
bundleProcessor.getStartFunctionRegistry();
       PTransformFunctionRegistry finishFunctionRegistry =
           bundleProcessor.getFinishFunctionRegistry();
       ExecutionStateTracker stateTracker = bundleProcessor.getStateTracker();
       ProcessBundleResponse.Builder response = 
ProcessBundleResponse.newBuilder();
       try (HandleStateCallsForBundle beamFnStateClient = 
bundleProcessor.getBeamFnStateClient()) {
-        stateTracker.start(request.getInstructionId());
+        stateTracker.start(instructionId);
         try {
           // Already in reverse topological order so we don't need to do 
anything.
           for (ThrowingRunnable startFunction : 
startFunctionRegistry.getFunctions()) {
@@ -545,12 +549,14 @@ public class ProcessBundleHandler {
           } else if 
(!bundleProcessor.getInboundEndpointApiServiceDescriptors().isEmpty()) {
             BeamFnDataInboundObserver observer = 
bundleProcessor.getInboundObserver();
             beamFnDataClient.registerReceiver(
-                request.getInstructionId(),
+                instructionId,
+                dataStreamId,
                 bundleProcessor.getInboundEndpointApiServiceDescriptors(),
                 observer);
             observer.awaitCompletion();
             beamFnDataClient.unregisterReceiver(
-                request.getInstructionId(),
+                instructionId,
+                dataStreamId,
                 bundleProcessor.getInboundEndpointApiServiceDescriptors());
           }
 
@@ -581,7 +587,7 @@ public class ProcessBundleHandler {
 
           if 
(!bundleProcessor.getBundleFinalizationCallbackRegistrations().isEmpty()) {
             finalizeBundleHandler.registerCallbacks(
-                bundleProcessor.getInstructionId(),
+                instructionId,
                 
ImmutableList.copyOf(bundleProcessor.getBundleFinalizationCallbackRegistrations()));
             response.setRequiresFinalization(true);
           }
@@ -599,7 +605,7 @@ public class ProcessBundleHandler {
     } catch (Exception e) {
       LOG.debug(
           "Error processing bundle {} with bundleProcessor for {} after 
exception",
-          request.getInstructionId(),
+          instructionId,
           request.getProcessBundle().getProcessBundleDescriptorId(),
           e);
       if (bundleProcessor != null) {
@@ -607,7 +613,7 @@ public class ProcessBundleHandler {
         bundleProcessorCache.discard(bundleProcessor);
       }
       // Ensure that if more data arrives for the instruction it is discarded.
-      beamFnDataClient.poisonInstructionId(request.getInstructionId());
+      beamFnDataClient.poisonInstructionId(instructionId);
       throw e;
     }
   }
@@ -629,6 +635,10 @@ public class ProcessBundleHandler {
       collectedElements.add(elements);
     }
     if (!hasFlushedAggregator) {
+      for (BeamFnDataOutboundAggregator aggregator :
+          bundleProcessor.getOutboundAggregators().values()) {
+        aggregator.finishInstruction();
+      }
       Elements.Builder elementsToEmbed = Elements.newBuilder();
       for (Elements collectedElement : collectedElements) {
         elementsToEmbed.mergeFrom(collectedElement);
@@ -645,6 +655,7 @@ public class ProcessBundleHandler {
         if (elements != null) {
           aggregator.sendElements(elements);
         }
+        aggregator.finishInstruction();
       }
     }
   }
@@ -875,7 +886,6 @@ public class ProcessBundleHandler {
 
       addRunnerAndConsumersForPTransformRecursively(
           beamFnStateClient,
-          beamFnDataClient,
           entry.getKey(),
           entry.getValue(),
           bundleProcessor::getInstructionId,
diff --git 
a/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/data/BeamFnDataClient.java
 
b/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/data/BeamFnDataClient.java
index 94d59d0fcb6..1a50f5b448c 100644
--- 
a/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/data/BeamFnDataClient.java
+++ 
b/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/data/BeamFnDataClient.java
@@ -18,13 +18,12 @@
 package org.apache.beam.fn.harness.data;
 
 import java.util.List;
-import java.util.function.Supplier;
 import org.apache.beam.model.fnexecution.v1.BeamFnApi.Elements;
 import org.apache.beam.model.pipeline.v1.Endpoints;
 import org.apache.beam.model.pipeline.v1.Endpoints.ApiServiceDescriptor;
-import org.apache.beam.sdk.fn.data.BeamFnDataOutboundAggregator;
 import org.apache.beam.sdk.fn.data.CloseableFnDataReceiver;
 import org.apache.beam.sdk.fn.data.FnDataReceiver;
+import org.apache.beam.vendor.grpc.v1p69p0.io.grpc.stub.StreamObserver;
 
 /**
  * The {@link BeamFnDataClient} is able to forward inbound elements to a 
{@link FnDataReceiver} and
@@ -47,6 +46,7 @@ public interface BeamFnDataClient {
    */
   void registerReceiver(
       String instructionId,
+      String dataStreamId,
       List<ApiServiceDescriptor> apiServiceDescriptors,
       CloseableFnDataReceiver<Elements> receiver);
 
@@ -58,7 +58,8 @@ public interface BeamFnDataClient {
    * to the {@link BeamFnDataClient} during a future {@link 
FnDataReceiver#accept} invocation or via
    * a call to {@link #poisonInstructionId}.
    */
-  void unregisterReceiver(String instructionId, List<ApiServiceDescriptor> 
apiServiceDescriptors);
+  void unregisterReceiver(
+      String instructionId, String dataStreamId, List<ApiServiceDescriptor> 
apiServiceDescriptors);
 
   /**
    * Poisons the instruction id, indicating that future data arriving for it 
should be discarded.
@@ -68,22 +69,7 @@ public interface BeamFnDataClient {
    */
   void poisonInstructionId(String instructionId);
 
-  /**
-   * Creates a {@link BeamFnDataOutboundAggregator} for buffering and sending 
outbound data and
-   * timers over the data plane. It is important that {@link
-   * 
BeamFnDataOutboundAggregator#sendOrCollectBufferedDataAndFinishOutboundStreams()}
 is called on
-   * the returned BeamFnDataOutboundAggregator at the end of each bundle. If
-   * collectElementsIfNoFlushes is set to true, {@link
-   * 
BeamFnDataOutboundAggregator#sendOrCollectBufferedDataAndFinishOutboundStreams()}
 returns the
-   * buffered elements instead of sending it through the outbound 
StreamObserver if there's no
-   * previous flush.
-   *
-   * <p>Closing the returned aggregator signals the end of the streams.
-   *
-   * <p>The returned aggregator is not thread safe.
-   */
-  BeamFnDataOutboundAggregator createOutboundAggregator(
-      Endpoints.ApiServiceDescriptor apiServiceDescriptor,
-      Supplier<String> processBundleRequestIdSupplier,
-      boolean collectElementsIfNoFlushes);
+  /** Get the outbound observer for the specified apiServiceDescriptor and 
dataStreamId. */
+  StreamObserver<Elements> getOutboundObserver(
+      Endpoints.ApiServiceDescriptor apiServiceDescriptor, String 
dataStreamId);
 }
diff --git 
a/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/data/BeamFnDataGrpcClient.java
 
b/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/data/BeamFnDataGrpcClient.java
index 499d816f8cc..2f2a6b0fc66 100644
--- 
a/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/data/BeamFnDataGrpcClient.java
+++ 
b/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/data/BeamFnDataGrpcClient.java
@@ -18,20 +18,22 @@
 package org.apache.beam.fn.harness.data;
 
 import java.util.List;
+import java.util.Objects;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentMap;
 import java.util.function.Function;
-import java.util.function.Supplier;
+import javax.annotation.Nullable;
 import org.apache.beam.model.fnexecution.v1.BeamFnApi.Elements;
 import org.apache.beam.model.fnexecution.v1.BeamFnDataGrpc;
 import org.apache.beam.model.pipeline.v1.Endpoints;
 import org.apache.beam.model.pipeline.v1.Endpoints.ApiServiceDescriptor;
 import org.apache.beam.sdk.fn.data.BeamFnDataGrpcMultiplexer;
-import org.apache.beam.sdk.fn.data.BeamFnDataOutboundAggregator;
 import org.apache.beam.sdk.fn.data.CloseableFnDataReceiver;
 import org.apache.beam.sdk.fn.stream.OutboundObserverFactory;
-import org.apache.beam.sdk.options.PipelineOptions;
 import org.apache.beam.vendor.grpc.v1p69p0.io.grpc.ManagedChannel;
+import org.apache.beam.vendor.grpc.v1p69p0.io.grpc.Metadata;
+import org.apache.beam.vendor.grpc.v1p69p0.io.grpc.stub.MetadataUtils;
+import org.apache.beam.vendor.grpc.v1p69p0.io.grpc.stub.StreamObserver;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -44,17 +46,42 @@ public class BeamFnDataGrpcClient implements 
BeamFnDataClient {
 
   private static final Logger LOG = 
LoggerFactory.getLogger(BeamFnDataGrpcClient.class);
 
-  private final ConcurrentMap<Endpoints.ApiServiceDescriptor, 
BeamFnDataGrpcMultiplexer>
-      multiplexerCache;
+  private static class MultiplexerKey {
+    private final Endpoints.ApiServiceDescriptor apiServiceDescriptor;
+    private final String dataStreamId;
+
+    private MultiplexerKey(
+        Endpoints.ApiServiceDescriptor apiServiceDescriptor, String 
dataStreamId) {
+      this.apiServiceDescriptor = apiServiceDescriptor;
+      this.dataStreamId = dataStreamId;
+    }
+
+    @Override
+    public boolean equals(@Nullable Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (!(o instanceof MultiplexerKey)) {
+        return false;
+      }
+      MultiplexerKey that = (MultiplexerKey) o;
+      return Objects.equals(dataStreamId, that.dataStreamId)
+          && Objects.equals(apiServiceDescriptor, that.apiServiceDescriptor);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(apiServiceDescriptor, dataStreamId);
+    }
+  }
+
+  private final ConcurrentMap<MultiplexerKey, BeamFnDataGrpcMultiplexer> 
multiplexerCache;
   private final Function<Endpoints.ApiServiceDescriptor, ManagedChannel> 
channelFactory;
   private final OutboundObserverFactory outboundObserverFactory;
-  private final PipelineOptions options;
 
   public BeamFnDataGrpcClient(
-      PipelineOptions options,
       Function<Endpoints.ApiServiceDescriptor, ManagedChannel> channelFactory,
       OutboundObserverFactory outboundObserverFactory) {
-    this.options = options;
     this.channelFactory = channelFactory;
     this.outboundObserverFactory = outboundObserverFactory;
     this.multiplexerCache = new ConcurrentHashMap<>();
@@ -63,21 +90,22 @@ public class BeamFnDataGrpcClient implements 
BeamFnDataClient {
   @Override
   public void registerReceiver(
       String instructionId,
+      String dataStreamId,
       List<ApiServiceDescriptor> apiServiceDescriptors,
       CloseableFnDataReceiver<Elements> receiver) {
     LOG.debug("Registering consumer for {}", instructionId);
     for (int i = 0, size = apiServiceDescriptors.size(); i < size; i++) {
-      BeamFnDataGrpcMultiplexer client = 
getClientFor(apiServiceDescriptors.get(i));
+      BeamFnDataGrpcMultiplexer client = 
getMultiplexer(apiServiceDescriptors.get(i), dataStreamId);
       client.registerConsumer(instructionId, receiver);
     }
   }
 
   @Override
   public void unregisterReceiver(
-      String instructionId, List<ApiServiceDescriptor> apiServiceDescriptors) {
+      String instructionId, String dataStreamId, List<ApiServiceDescriptor> 
apiServiceDescriptors) {
     LOG.debug("Unregistering consumer for {}", instructionId);
     for (int i = 0, size = apiServiceDescriptors.size(); i < size; i++) {
-      BeamFnDataGrpcMultiplexer client = 
getClientFor(apiServiceDescriptors.get(i));
+      BeamFnDataGrpcMultiplexer client = 
getMultiplexer(apiServiceDescriptors.get(i), dataStreamId);
       client.unregisterConsumer(instructionId);
     }
   }
@@ -91,25 +119,32 @@ public class BeamFnDataGrpcClient implements 
BeamFnDataClient {
   }
 
   @Override
-  public BeamFnDataOutboundAggregator createOutboundAggregator(
-      ApiServiceDescriptor apiServiceDescriptor,
-      Supplier<String> processBundleRequestIdSupplier,
-      boolean collectElementsIfNoFlushes) {
-    return new BeamFnDataOutboundAggregator(
-        options,
-        processBundleRequestIdSupplier,
-        getClientFor(apiServiceDescriptor).getOutboundObserver(),
-        collectElementsIfNoFlushes);
+  public StreamObserver<Elements> getOutboundObserver(
+      ApiServiceDescriptor apiServiceDescriptor, String dataStreamId) {
+    return getMultiplexer(apiServiceDescriptor, 
dataStreamId).getOutboundObserver();
   }
 
-  private BeamFnDataGrpcMultiplexer getClientFor(
-      Endpoints.ApiServiceDescriptor apiServiceDescriptor) {
+  private BeamFnDataGrpcMultiplexer getMultiplexer(
+      Endpoints.ApiServiceDescriptor apiServiceDescriptor, String 
dataStreamId) {
+    MultiplexerKey key = new MultiplexerKey(apiServiceDescriptor, 
dataStreamId);
     return multiplexerCache.computeIfAbsent(
-        apiServiceDescriptor,
-        (Endpoints.ApiServiceDescriptor descriptor) ->
-            new BeamFnDataGrpcMultiplexer(
-                descriptor,
-                outboundObserverFactory,
-                
BeamFnDataGrpc.newStub(channelFactory.apply(apiServiceDescriptor))::data));
+        key,
+        k -> {
+          OutboundObserverFactory.BasicFactory<Elements, Elements> 
baseOutboundObserverFactory =
+              inboundObserver -> {
+                BeamFnDataGrpc.BeamFnDataStub stub =
+                    
BeamFnDataGrpc.newStub(channelFactory.apply(apiServiceDescriptor));
+                if (dataStreamId != null && !dataStreamId.isEmpty()) {
+                  Metadata headers = new Metadata();
+                  headers.put(
+                      Metadata.Key.of("data_stream_id", 
Metadata.ASCII_STRING_MARSHALLER),
+                      dataStreamId);
+                  stub = 
stub.withInterceptors(MetadataUtils.newAttachHeadersInterceptor(headers));
+                }
+                return stub.data(inboundObserver);
+              };
+          return new BeamFnDataGrpcMultiplexer(
+              apiServiceDescriptor, outboundObserverFactory, 
baseOutboundObserverFactory);
+        });
   }
 }
diff --git 
a/sdks/java/harness/src/test/java/org/apache/beam/fn/harness/BeamFnDataWriteRunnerTest.java
 
b/sdks/java/harness/src/test/java/org/apache/beam/fn/harness/BeamFnDataWriteRunnerTest.java
index 70a894e7b37..2882b75a259 100644
--- 
a/sdks/java/harness/src/test/java/org/apache/beam/fn/harness/BeamFnDataWriteRunnerTest.java
+++ 
b/sdks/java/harness/src/test/java/org/apache/beam/fn/harness/BeamFnDataWriteRunnerTest.java
@@ -32,7 +32,6 @@ import java.util.List;
 import java.util.Map;
 import java.util.ServiceLoader;
 import java.util.concurrent.atomic.AtomicReference;
-import java.util.function.Supplier;
 import org.apache.beam.fn.harness.PTransformRunnerFactory.Registrar;
 import org.apache.beam.fn.harness.data.BeamFnDataClient;
 import org.apache.beam.model.fnexecution.v1.BeamFnApi;
@@ -107,19 +106,29 @@ public class BeamFnDataWriteRunnerTest {
     MockitoAnnotations.initMocks(this);
   }
 
-  private BeamFnDataOutboundAggregator createRecordingAggregator(
-      Map<String, List<WindowedValue<String>>> output, Supplier<String> 
bundleId) {
+  @Test
+  public void testReuseForMultipleBundles() throws Exception {
+    AtomicReference<String> bundleId = new AtomicReference<>("0");
+    String localInputId = "inputPC";
+    RunnerApi.PTransform pTransform =
+        RemoteGrpcPortWrite.writeToPort(localInputId, 
PORT_SPEC).toPTransform();
+
+    List<WindowedValue<String>> output0 = new ArrayList<>();
+    List<WindowedValue<String>> output1 = new ArrayList<>();
+    Map<ApiServiceDescriptor, BeamFnDataOutboundAggregator> aggregators = new 
HashMap<>();
+
     PipelineOptions options = PipelineOptionsFactory.create();
     
options.as(ExperimentalOptions.class).setExperiments(Arrays.asList("data_buffer_size_limit=0"));
-    return new BeamFnDataOutboundAggregator(
-        options,
-        bundleId,
+    BeamFnDataOutboundAggregator aggregator = new 
BeamFnDataOutboundAggregator(options, false);
+
+    Map<String, List<WindowedValue<String>>> outputs = ImmutableMap.of("0", 
output0, "1", output1);
+    StreamObserver<Elements> observer =
         new StreamObserver<Elements>() {
           @Override
           public void onNext(Elements elements) {
             for (Data data : elements.getDataList()) {
               try {
-                
output.get(bundleId.get()).add(WIRE_CODER.decode(data.getData().newInput()));
+                
outputs.get(bundleId.get()).add(WIRE_CODER.decode(data.getData().newInput()));
               } catch (IOException e) {
                 throw new RuntimeException("Failed to decode output.");
               }
@@ -131,22 +140,9 @@ public class BeamFnDataWriteRunnerTest {
 
           @Override
           public void onCompleted() {}
-        },
-        false);
-  }
-
-  @Test
-  public void testReuseForMultipleBundles() throws Exception {
-    AtomicReference<String> bundleId = new AtomicReference<>("0");
-    String localInputId = "inputPC";
-    RunnerApi.PTransform pTransform =
-        RemoteGrpcPortWrite.writeToPort(localInputId, 
PORT_SPEC).toPTransform();
+        };
 
-    List<WindowedValue<String>> output0 = new ArrayList<>();
-    List<WindowedValue<String>> output1 = new ArrayList<>();
-    Map<ApiServiceDescriptor, BeamFnDataOutboundAggregator> aggregators = new 
HashMap<>();
-    BeamFnDataOutboundAggregator aggregator =
-        createRecordingAggregator(ImmutableMap.of("0", output0, "1", output1), 
bundleId::get);
+    aggregator.prepareForInstruction(bundleId.get(), observer);
     aggregators.put(PORT_SPEC.getApiServiceDescriptor(), aggregator);
 
     PTransformRunnerFactoryTestContext context =
@@ -172,18 +168,20 @@ public class BeamFnDataWriteRunnerTest {
     FnDataReceiver<Object> pCollectionConsumer = 
context.getPCollectionConsumer(localInputId);
     pCollectionConsumer.accept(valueInGlobalWindow("ABC"));
     pCollectionConsumer.accept(valueInGlobalWindow("DEF"));
-
     assertThat(output0, contains(valueInGlobalWindow("ABC"), 
valueInGlobalWindow("DEF")));
+    aggregator.finishInstruction();
 
     output0.clear();
 
     // Process for bundle id 1
     bundleId.set("1");
+    aggregator.prepareForInstruction(bundleId.get(), observer);
 
     pCollectionConsumer.accept(valueInGlobalWindow("GHI"));
     pCollectionConsumer.accept(valueInGlobalWindow("JKL"));
 
     assertThat(output1, contains(valueInGlobalWindow("GHI"), 
valueInGlobalWindow("JKL")));
+    aggregator.finishInstruction();
     verifyNoMoreInteractions(mockBeamFnDataClient);
   }
 
diff --git 
a/sdks/java/harness/src/test/java/org/apache/beam/fn/harness/FnApiDoFnRunnerTest.java
 
b/sdks/java/harness/src/test/java/org/apache/beam/fn/harness/FnApiDoFnRunnerTest.java
index 50a2fec0b5a..2aa555e83cd 100644
--- 
a/sdks/java/harness/src/test/java/org/apache/beam/fn/harness/FnApiDoFnRunnerTest.java
+++ 
b/sdks/java/harness/src/test/java/org/apache/beam/fn/harness/FnApiDoFnRunnerTest.java
@@ -839,7 +839,7 @@ public class FnApiDoFnRunnerTest implements Serializable {
       private Supplier<String> processBundleRequestIdSupplier;
 
       public TestBeamFnDataOutboundAggregator(Supplier<String> 
bundleIdSupplier) {
-        super(PipelineOptionsFactory.create(), bundleIdSupplier, null, false);
+        super(PipelineOptionsFactory.create(), false);
         this.timers = new HashMap<>();
         this.dataOutput = new HashMap<>();
         this.processBundleRequestIdSupplier = bundleIdSupplier;
diff --git 
a/sdks/java/harness/src/test/java/org/apache/beam/fn/harness/PTransformRunnerFactoryTestContext.java
 
b/sdks/java/harness/src/test/java/org/apache/beam/fn/harness/PTransformRunnerFactoryTestContext.java
index 7b4387738a4..51e49953b40 100644
--- 
a/sdks/java/harness/src/test/java/org/apache/beam/fn/harness/PTransformRunnerFactoryTestContext.java
+++ 
b/sdks/java/harness/src/test/java/org/apache/beam/fn/harness/PTransformRunnerFactoryTestContext.java
@@ -54,6 +54,7 @@ import org.apache.beam.sdk.transforms.DoFn;
 import org.apache.beam.sdk.transforms.DoFn.BundleFinalizer;
 import org.apache.beam.sdk.util.construction.Timer;
 import org.apache.beam.sdk.values.WindowedValue;
+import org.apache.beam.vendor.grpc.v1p69p0.io.grpc.stub.StreamObserver;
 import org.joda.time.Instant;
 
 /**
@@ -74,6 +75,7 @@ public abstract class PTransformRunnerFactoryTestContext
               @Override
               public void registerReceiver(
                   String instructionId,
+                  String dataStreamId,
                   List<ApiServiceDescriptor> apiServiceDescriptors,
                   CloseableFnDataReceiver<Elements> receiver) {
                 throw new UnsupportedOperationException("Unexpected call 
during test.");
@@ -81,15 +83,15 @@ public abstract class PTransformRunnerFactoryTestContext
 
               @Override
               public void unregisterReceiver(
-                  String instructionId, List<ApiServiceDescriptor> 
apiServiceDescriptors) {
+                  String instructionId,
+                  String dataStreamId,
+                  List<ApiServiceDescriptor> apiServiceDescriptors) {
                 throw new UnsupportedOperationException("Unexpected call 
during test.");
               }
 
               @Override
-              public BeamFnDataOutboundAggregator createOutboundAggregator(
-                  ApiServiceDescriptor apiServiceDescriptor,
-                  Supplier<String> processBundleRequestIdSupplier,
-                  boolean collectElementsIfNoFlushes) {
+              public StreamObserver<Elements> getOutboundObserver(
+                  ApiServiceDescriptor apiServiceDescriptor, String 
dataStreamId) {
                 throw new UnsupportedOperationException("Unexpected call 
during test.");
               }
 
diff --git 
a/sdks/java/harness/src/test/java/org/apache/beam/fn/harness/control/ProcessBundleHandlerTest.java
 
b/sdks/java/harness/src/test/java/org/apache/beam/fn/harness/control/ProcessBundleHandlerTest.java
index 47f85178b0a..c03f8272674 100644
--- 
a/sdks/java/harness/src/test/java/org/apache/beam/fn/harness/control/ProcessBundleHandlerTest.java
+++ 
b/sdks/java/harness/src/test/java/org/apache/beam/fn/harness/control/ProcessBundleHandlerTest.java
@@ -37,7 +37,6 @@ import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyBoolean;
 import static org.mockito.Mockito.argThat;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.eq;
@@ -1071,28 +1070,20 @@ public class ProcessBundleHandlerTest {
                   dataOutput.add(input.getValue());
                 }));
 
-    Mockito.doAnswer(
-            (invocation) ->
-                new BeamFnDataOutboundAggregator(
-                    PipelineOptionsFactory.create(),
-                    invocation.getArgument(1),
-                    new StreamObserver<Elements>() {
-                      @Override
-                      public void onNext(Elements elements) {
-                        for (Timers timer : elements.getTimersList()) {
-                          timerOutput.addAll(elements.getTimersList());
-                        }
-                      }
+    Mockito.when(beamFnDataClient.getOutboundObserver(any(), any()))
+        .thenReturn(
+            new StreamObserver<Elements>() {
+              @Override
+              public void onNext(Elements elements) {
+                timerOutput.addAll(elements.getTimersList());
+              }
 
-                      @Override
-                      public void onError(Throwable throwable) {}
+              @Override
+              public void onError(Throwable throwable) {}
 
-                      @Override
-                      public void onCompleted() {}
-                    },
-                    invocation.getArgument(2)))
-        .when(beamFnDataClient)
-        .createOutboundAggregator(any(), any(), anyBoolean());
+              @Override
+              public void onCompleted() {}
+            });
 
     return new ProcessBundleHandler(
         PipelineOptionsFactory.create(),
@@ -1409,7 +1400,7 @@ public class ProcessBundleHandlerTest {
             (invocation) -> {
               String instructionId = invocation.getArgument(0, String.class);
               CloseableFnDataReceiver<BeamFnApi.Elements> data =
-                  invocation.getArgument(2, CloseableFnDataReceiver.class);
+                  invocation.getArgument(3, CloseableFnDataReceiver.class);
               data.accept(
                   BeamFnApi.Elements.newBuilder()
                       .addData(
@@ -1421,7 +1412,7 @@ public class ProcessBundleHandlerTest {
               return null;
             })
         .when(beamFnDataClient)
-        .registerReceiver(any(), any(), any());
+        .registerReceiver(any(), any(), any(), any());
 
     ProcessBundleHandler handler =
         new ProcessBundleHandler(
@@ -1451,8 +1442,8 @@ public class ProcessBundleHandlerTest {
             .build());
 
     // Ensure that we unregister during successful processing
-    verify(beamFnDataClient).registerReceiver(eq("instructionId"), any(), 
any());
-    verify(beamFnDataClient).unregisterReceiver(eq("instructionId"), any());
+    verify(beamFnDataClient).registerReceiver(eq("instructionId"), any(), 
any(), any());
+    verify(beamFnDataClient).unregisterReceiver(eq("instructionId"), any(), 
any());
     verifyNoMoreInteractions(beamFnDataClient);
   }
 
@@ -1475,7 +1466,7 @@ public class ProcessBundleHandlerTest {
               StringUtf8Coder.of().encode("A", encodedData);
               String instructionId = invocation.getArgument(0, String.class);
               CloseableFnDataReceiver<BeamFnApi.Elements> data =
-                  invocation.getArgument(2, CloseableFnDataReceiver.class);
+                  invocation.getArgument(3, CloseableFnDataReceiver.class);
               data.accept(
                   BeamFnApi.Elements.newBuilder()
                       .addData(
@@ -1489,7 +1480,7 @@ public class ProcessBundleHandlerTest {
               return null;
             })
         .when(beamFnDataClient)
-        .registerReceiver(any(), any(), any());
+        .registerReceiver(any(), any(), any(), any());
 
     ProcessBundleHandler handler =
         new ProcessBundleHandler(
@@ -1526,7 +1517,7 @@ public class ProcessBundleHandlerTest {
                     .build()));
 
     // Ensure that we unregister during successful processing
-    verify(beamFnDataClient).registerReceiver(eq("instructionId"), any(), 
any());
+    verify(beamFnDataClient).registerReceiver(eq("instructionId"), any(), 
any(), any());
     verify(beamFnDataClient).poisonInstructionId(eq("instructionId"));
     verifyNoMoreInteractions(beamFnDataClient);
   }
diff --git 
a/sdks/java/harness/src/test/java/org/apache/beam/fn/harness/data/BeamFnDataGrpcClientTest.java
 
b/sdks/java/harness/src/test/java/org/apache/beam/fn/harness/data/BeamFnDataGrpcClientTest.java
index 15f83f2582c..9d9efa0b9c4 100644
--- 
a/sdks/java/harness/src/test/java/org/apache/beam/fn/harness/data/BeamFnDataGrpcClientTest.java
+++ 
b/sdks/java/harness/src/test/java/org/apache/beam/fn/harness/data/BeamFnDataGrpcClientTest.java
@@ -23,8 +23,8 @@ import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.contains;
 import static org.hamcrest.Matchers.empty;
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
-import static org.junit.Assert.fail;
 
 import java.util.Arrays;
 import java.util.Collection;
@@ -49,6 +49,7 @@ import org.apache.beam.sdk.fn.data.FnDataReceiver;
 import org.apache.beam.sdk.fn.data.LogicalEndpoint;
 import org.apache.beam.sdk.fn.stream.OutboundObserverFactory;
 import org.apache.beam.sdk.fn.test.TestStreams;
+import org.apache.beam.sdk.options.PipelineOptions;
 import org.apache.beam.sdk.options.PipelineOptionsFactory;
 import org.apache.beam.sdk.transforms.windowing.GlobalWindow;
 import org.apache.beam.sdk.values.WindowedValue;
@@ -169,7 +170,6 @@ public class BeamFnDataGrpcClientTest {
 
       BeamFnDataGrpcClient clientFactory =
           new BeamFnDataGrpcClient(
-              PipelineOptionsFactory.create(),
               (Endpoints.ApiServiceDescriptor descriptor) -> channel,
               OutboundObserverFactory.trivial());
 
@@ -183,7 +183,7 @@ public class BeamFnDataGrpcClientTest {
               Collections.emptyList());
 
       clientFactory.registerReceiver(
-          INSTRUCTION_ID_A, Arrays.asList(apiServiceDescriptor), observerA);
+          INSTRUCTION_ID_A, "", Arrays.asList(apiServiceDescriptor), 
observerA);
 
       waitForClientToConnect.await();
       outboundServerObserver.get().onNext(ELEMENTS_A_1);
@@ -193,7 +193,7 @@ public class BeamFnDataGrpcClientTest {
       Thread.sleep(100);
 
       clientFactory.registerReceiver(
-          INSTRUCTION_ID_B, Arrays.asList(apiServiceDescriptor), observerB);
+          INSTRUCTION_ID_B, "", Arrays.asList(apiServiceDescriptor), 
observerB);
 
       // Show that out of order stream completion can occur.
       observerB.awaitCompletion();
@@ -245,7 +245,6 @@ public class BeamFnDataGrpcClientTest {
 
       BeamFnDataGrpcClient clientFactory =
           new BeamFnDataGrpcClient(
-              PipelineOptionsFactory.create(),
               (Endpoints.ApiServiceDescriptor descriptor) -> channel,
               OutboundObserverFactory.trivial());
 
@@ -262,7 +261,7 @@ public class BeamFnDataGrpcClientTest {
               Collections.emptyList());
 
       clientFactory.registerReceiver(
-          INSTRUCTION_ID_A, Arrays.asList(apiServiceDescriptor), observer);
+          INSTRUCTION_ID_A, "", Arrays.asList(apiServiceDescriptor), observer);
 
       waitForClientToConnect.await();
 
@@ -270,12 +269,8 @@ public class BeamFnDataGrpcClientTest {
       outboundServerObserver.get().onNext(ELEMENTS_A_1);
       outboundServerObserver.get().onNext(ELEMENTS_A_2);
 
-      try {
-        observer.awaitCompletion();
-        fail("Expected channel to fail");
-      } catch (Exception e) {
-        assertEquals(exceptionToThrow, e);
-      }
+      Exception e = assertThrows(Exception.class, observer::awaitCompletion);
+      assertEquals(exceptionToThrow, e);
       // The server should not have received any values
       assertThat(inboundServerValues, empty());
       // The consumer should have only been invoked once
@@ -321,7 +316,6 @@ public class BeamFnDataGrpcClientTest {
 
       BeamFnDataGrpcClient clientFactory =
           new BeamFnDataGrpcClient(
-              PipelineOptionsFactory.create(),
               (Endpoints.ApiServiceDescriptor descriptor) -> channel,
               OutboundObserverFactory.trivial());
 
@@ -347,7 +341,7 @@ public class BeamFnDataGrpcClientTest {
               });
 
       clientFactory.registerReceiver(
-          INSTRUCTION_ID_A, Arrays.asList(apiServiceDescriptor), observerA);
+          INSTRUCTION_ID_A, "", Arrays.asList(apiServiceDescriptor), 
observerA);
 
       waitForClientToConnect.await();
       outboundServerObserver.get().onNext(ELEMENTS_B_1);
@@ -358,11 +352,9 @@ public class BeamFnDataGrpcClientTest {
       assertTrue(receivedAElement.await(5, TimeUnit.SECONDS));
 
       clientFactory.poisonInstructionId(INSTRUCTION_ID_A);
-      try {
-        future.get();
-        fail(); // We expect the awaitCompletion to fail due to closing.
-      } catch (Exception ignored) {
-      }
+      // We expect the awaitCompletion to fail due to closing.
+      // Expected.
+      assertThrows(Exception.class, future::get);
 
       outboundServerObserver.get().onNext(ELEMENTS_A_2);
 
@@ -404,16 +396,15 @@ public class BeamFnDataGrpcClientTest {
       ManagedChannel channel =
           
InProcessChannelBuilder.forName(apiServiceDescriptor.getUrl()).build();
 
+      PipelineOptions options =
+          
PipelineOptionsFactory.fromArgs("--experiments=data_buffer_size_limit=20").create();
       BeamFnDataGrpcClient clientFactory =
           new BeamFnDataGrpcClient(
-              PipelineOptionsFactory.fromArgs(
-                      new String[] {"--experiments=data_buffer_size_limit=20"})
-                  .create(),
               (Endpoints.ApiServiceDescriptor descriptor) -> channel,
               OutboundObserverFactory.trivial());
-      BeamFnDataOutboundAggregator aggregator =
-          clientFactory.createOutboundAggregator(
-              apiServiceDescriptor, () -> INSTRUCTION_ID_A, false);
+      BeamFnDataOutboundAggregator aggregator = new 
BeamFnDataOutboundAggregator(options, false);
+      aggregator.prepareForInstruction(
+          INSTRUCTION_ID_A, 
clientFactory.getOutboundObserver(apiServiceDescriptor, ""));
       FnDataReceiver<WindowedValue<String>> fnDataReceiver =
           aggregator.registerOutputDataLocation(TRANSFORM_ID_A, CODER);
       fnDataReceiver.accept(valueInGlobalWindow("ABC"));

Reply via email to