public static ExecutableStage forGrpcPortRead(
      QueryablePipeline pipeline,
      PipelineNode.PCollectionNode inputPCollection,
      Set<PipelineNode.PTransformNode> initialNodes) {
    checkArgument(
        !initialNodes.isEmpty(),
        "%s must contain at least one %s.",
        GreedyStageFuser.class.getSimpleName(),
        PipelineNode.PTransformNode.class.getSimpleName());
    // Choose the environment from an arbitrary node. The initial nodes may not be empty for this
    // subgraph to make any sense, there has to be at least one processor node
    // (otherwise the stage is gRPC Read -> gRPC Write, which doesn't do anything).
    Environment environment = getStageEnvironment(pipeline, initialNodes);

    ImmutableSet.Builder<PipelineNode.PTransformNode> fusedTransforms = ImmutableSet.builder();
    fusedTransforms.addAll(initialNodes);

    Set<SideInputReference> sideInputs = new LinkedHashSet<>();
    Set<UserStateReference> userStates = new LinkedHashSet<>();
    Set<TimerReference> timers = new LinkedHashSet<>();
    Set<PipelineNode.PCollectionNode> fusedCollections = new LinkedHashSet<>();
    Set<PipelineNode.PCollectionNode> materializedPCollections = new LinkedHashSet<>();

    Queue<PipelineNode.PCollectionNode> fusionCandidates = new ArrayDeque<>();
    for (PipelineNode.PTransformNode initialConsumer : initialNodes) {
      fusionCandidates.addAll(pipeline.getOutputPCollections(initialConsumer));
      sideInputs.addAll(pipeline.getSideInputs(initialConsumer));
      userStates.addAll(pipeline.getUserStates(initialConsumer));
      timers.addAll(pipeline.getTimers(initialConsumer));
    }
    while (!fusionCandidates.isEmpty()) {
      PipelineNode.PCollectionNode candidate = fusionCandidates.poll();
      if (fusedCollections.contains(candidate) || materializedPCollections.contains(candidate)) {
        // This should generally mean we get to a Flatten via multiple paths through the graph and
        // we've already determined what to do with the output.
        LOG.debug(
            "Skipping fusion candidate {} because it is {} in this {}",
            candidate,
            fusedCollections.contains(candidate) ? "fused" : "materialized",
            ExecutableStage.class.getSimpleName());
        continue;
      }
      PCollectionFusibility fusibility =
          canFuse(pipeline, candidate, environment, fusedCollections);
      switch (fusibility) {
        case MATERIALIZE:
          materializedPCollections.add(candidate);
          break;
        case FUSE:
          // All of the consumers of the candidate PCollection can be fused into this stage. Do so.
          fusedCollections.add(candidate);
          fusedTransforms.addAll(pipeline.getPerElementConsumers(candidate));
          for (PipelineNode.PTransformNode consumer : pipeline.getPerElementConsumers(candidate)) {
            // The outputs of every transform fused into this stage must be either materialized or
            // themselves fused away, so add them to the set of candidates.
            fusionCandidates.addAll(pipeline.getOutputPCollections(consumer));
            sideInputs.addAll(pipeline.getSideInputs(consumer));
          }
          break;
        default:
          throw new IllegalStateException(
              String.format(
                  "Unknown type of %s %s",
                  PCollectionFusibility.class.getSimpleName(), fusibility));
      }
    }

    return ImmutableExecutableStage.ofFullComponents(
        pipeline.getComponents(),
        environment,
        inputPCollection,
        sideInputs,
        userStates,
        timers,
        fusedTransforms.build(),
        materializedPCollections,
        ExecutableStage.DEFAULT_WIRE_CODER_SETTINGS);
  }
@Test
  public void flattenWithHeterogeneousInputsAndOutputs() {
    // (impulse.out) -> pyRead -> pyRead.out \                           -> pyParDo -> pyParDo.out
    // (impulse.out) ->                       -> flatten -> flatten.out |
    // (impulse.out) -> goRead -> goRead.out /                           -> goWindow -> goWindow.out
    // fuses into
    // (impulse.out) -> pyRead -> pyRead.out -> flatten -> (flatten.out)
    // (impulse.out) -> goRead -> goRead.out -> flatten -> (flatten.out)
    // (flatten.out) -> pyParDo -> pyParDo.out
    // (flatten.out) -> goWindow -> goWindow.out
    PTransform pyRead =
        PTransform.newBuilder()
            .putInputs("input", "impulse.out")
            .putOutputs("output", "pyRead.out")
            .setSpec(
                FunctionSpec.newBuilder()
                    .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                    .setPayload(
                        ParDoPayload.newBuilder()
                            .setDoFn(FunctionSpec.newBuilder())
                            .build()
                            .toByteString())
                    .build())
            .setEnvironmentId("py")
            .build();
    PTransform goRead =
        PTransform.newBuilder()
            .putInputs("input", "impulse.out")
            .putOutputs("output", "goRead.out")
            .setSpec(
                FunctionSpec.newBuilder()
                    .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                    .setPayload(
                        ParDoPayload.newBuilder()
                            .setDoFn(FunctionSpec.newBuilder())
                            .build()
                            .toByteString())
                    .build())
            .setEnvironmentId("go")
            .build();

    PTransform pyParDo =
        PTransform.newBuilder()
            .putInputs("input", "flatten.out")
            .putOutputs("output", "pyParDo.out")
            .setSpec(
                FunctionSpec.newBuilder()
                    .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                    .setPayload(
                        ParDoPayload.newBuilder()
                            .setDoFn(FunctionSpec.newBuilder())
                            .build()
                            .toByteString())
                    .build())
            .setEnvironmentId("py")
            .build();
    PTransform goWindow =
        PTransform.newBuilder()
            .putInputs("input", "flatten.out")
            .putOutputs("output", "goWindow.out")
            .setSpec(
                FunctionSpec.newBuilder()
                    .setUrn(PTransformTranslation.ASSIGN_WINDOWS_TRANSFORM_URN)
                    .setPayload(
                        WindowIntoPayload.newBuilder()
                            .setWindowFn(FunctionSpec.newBuilder())
                            .build()
                            .toByteString())
                    .build())
            .setEnvironmentId("go")
            .build();

    PCollection flattenPc = PCollection.newBuilder().setUniqueName("flatten.out").build();
    Components components =
        partialComponents
            .toBuilder()
            .putTransforms("pyRead", pyRead)
            .putPcollections(
                "pyRead.out", PCollection.newBuilder().setUniqueName("pyRead.out").build())
            .putTransforms("goRead", goRead)
            .putPcollections(
                "goRead.out", PCollection.newBuilder().setUniqueName("goRead.out").build())
            .putTransforms(
                "flatten",
                PTransform.newBuilder()
                    .putInputs("py_input", "pyRead.out")
                    .putInputs("go_input", "goRead.out")
                    .putOutputs("output", "flatten.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.FLATTEN_TRANSFORM_URN)
                            .build())
                    .build())
            .putPcollections("flatten.out", flattenPc)
            .putTransforms("pyParDo", pyParDo)
            .putPcollections(
                "pyParDo.out", PCollection.newBuilder().setUniqueName("pyParDo.out").build())
            .putTransforms("goWindow", goWindow)
            .putPcollections(
                "goWindow.out", PCollection.newBuilder().setUniqueName("goWindow.out").build())
            .putEnvironments("go", Environments.createDockerEnvironment("go"))
            .putEnvironments("py", Environments.createDockerEnvironment("py"))
            .build();
    QueryablePipeline p = QueryablePipeline.forPrimitivesIn(components);

    ExecutableStage readFromPy =
        GreedyStageFuser.forGrpcPortRead(
            p, impulseOutputNode, ImmutableSet.of(PipelineNode.pTransform("pyRead", pyRead)));
    ExecutableStage readFromGo =
        GreedyStageFuser.forGrpcPortRead(
            p, impulseOutputNode, ImmutableSet.of(PipelineNode.pTransform("goRead", goRead)));

    assertThat(
        readFromPy.getOutputPCollections(),
        contains(PipelineNode.pCollection("flatten.out", flattenPc)));
    // The stage must materialize the flatten, so the `go` stage can read it; this means that this
    // parDo can't be in the stage, as it'll be a reader of that materialized PCollection. The same
    // is true for the go window.
    assertThat(
        readFromPy.getTransforms(), not(hasItem(PipelineNode.pTransform("pyParDo", pyParDo))));

    assertThat(
        readFromGo.getOutputPCollections(),
        contains(PipelineNode.pCollection("flatten.out", flattenPc)));
    assertThat(
        readFromGo.getTransforms(), not(hasItem(PipelineNode.pTransform("goWindow", goWindow))));
  }