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

xuang7 pushed a commit to branch release/v1.2
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/release/v1.2 by this push:
     new f0a18ffbed fix(amber, v1.2): advance region executions in a later 
coordinator round, not inside portCompleted (#7096)
f0a18ffbed is described below

commit f0a18ffbed6484bf59cb5d8788c69a09a022e6a9
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Wed Jul 29 23:28:21 2026 -0700

    fix(amber, v1.2): advance region executions in a later coordinator round, 
not inside portCompleted (#7096)
    
    ### What changes were proposed in this PR?
    
    Automated backport of #6960 to `release/v1.2`.
    
    Source: 226614b527bf51a010355778440e1972ed3130a7 · [automation
    run](https://github.com/apache/texera/actions/runs/30506845994)
    
    ### Any related issues, documentation, discussions?
    
    Backport of #6960.
    
    ### How was this PR tested?
    
    Release-branch CI runs on this branch once the conflicts are resolved
    and this PR is marked ready for review.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    No.
    
    Co-authored-by: Xinyuan Lin <[email protected]>
---
 .../architecture/rpc/controllerservice.proto       |   1 +
 .../ControllerAsyncRPCHandlerInitializer.scala     |   1 +
 .../AdvanceRegionExecutionsHandler.scala           |  64 +++++
 .../promisehandlers/PortCompletedHandler.scala     |  28 +-
 .../scheduling/RegionExecutionCoordinator.scala    |  12 +-
 .../scheduling/WorkflowExecutionCoordinator.scala  |   4 +
 .../worker/promisehandlers/EndHandler.scala        |  65 ++++-
 .../promisehandlers/PortCompletedHandlerSpec.scala | 282 +++++++++++++++++++++
 .../worker/promisehandlers/EndHandlerSpec.scala    |  69 ++++-
 9 files changed, 502 insertions(+), 24 deletions(-)

diff --git 
a/amber/src/main/protobuf/org/apache/texera/amber/engine/architecture/rpc/controllerservice.proto
 
b/amber/src/main/protobuf/org/apache/texera/amber/engine/architecture/rpc/controllerservice.proto
index 0932a7b914..13c916cd8a 100644
--- 
a/amber/src/main/protobuf/org/apache/texera/amber/engine/architecture/rpc/controllerservice.proto
+++ 
b/amber/src/main/protobuf/org/apache/texera/amber/engine/architecture/rpc/controllerservice.proto
@@ -45,6 +45,7 @@ service ControllerService {
   rpc JumpToOperatorRegion(JumpToOperatorRegionRequest) returns (EmptyReturn);
   rpc LinkWorkers(LinkWorkersRequest) returns (EmptyReturn);
   rpc ControllerInitiateQueryStatistics(QueryStatisticsRequest) returns 
(EmptyReturn);
+  rpc ControllerInitiateAdvanceRegionExecutions(EmptyRequest) returns 
(EmptyReturn);
   rpc RetryWorkflow(RetryWorkflowRequest) returns (EmptyReturn);
   rpc ReconfigureWorkflow(WorkflowReconfigureRequest) returns (EmptyReturn);
 }
diff --git 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/ControllerAsyncRPCHandlerInitializer.scala
 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/ControllerAsyncRPCHandlerInitializer.scala
index 7e5a904716..19c960da30 100644
--- 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/ControllerAsyncRPCHandlerInitializer.scala
+++ 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/ControllerAsyncRPCHandlerInitializer.scala
@@ -41,6 +41,7 @@ class ControllerAsyncRPCHandlerInitializer(
     with ResumeHandler
     with StartWorkflowHandler
     with PortCompletedHandler
+    with AdvanceRegionExecutionsHandler
     with ConsoleMessageHandler
     with RetryWorkflowHandler
     with EvaluatePythonExpressionHandler
diff --git 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/promisehandlers/AdvanceRegionExecutionsHandler.scala
 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/promisehandlers/AdvanceRegionExecutionsHandler.scala
new file mode 100644
index 0000000000..305be181a1
--- /dev/null
+++ 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/promisehandlers/AdvanceRegionExecutionsHandler.scala
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.texera.amber.engine.architecture.controller.promisehandlers
+
+import com.twitter.util.Future
+import org.apache.texera.amber.core.WorkflowRuntimeException
+import org.apache.texera.amber.engine.architecture.controller.{
+  ControllerAsyncRPCHandlerInitializer,
+  FatalError
+}
+import org.apache.texera.amber.engine.architecture.rpc.controlcommands.{
+  AsyncRPCContext,
+  EmptyRequest
+}
+import 
org.apache.texera.amber.engine.architecture.rpc.controlreturns.EmptyReturn
+
+/** Advance the region executions of this workflow.
+  *
+  * A handler that needs region executions advanced sends this to itself 
rather than advancing
+  * inline (see `PortCompletedHandler`), so that the advance — which 
terminates completed regions
+  * and therefore sends `EndWorker` to their workers — runs in its own control 
round, after every
+  * reply the requesting round owed.
+  *
+  * possible sender: controller (itself)
+  */
+trait AdvanceRegionExecutionsHandler {
+  this: ControllerAsyncRPCHandlerInitializer =>
+
+  override def controllerInitiateAdvanceRegionExecutions(
+      request: EmptyRequest,
+      ctx: AsyncRPCContext
+  ): Future[EmptyReturn] = {
+    cp.workflowExecutionCoordinator
+      .coordinateRegionExecutors(cp.actorService)
+      // The requester is the controller itself and discards this reply, so a 
failure has no
+      // caller to propagate to. A fatal error is sent to the client, 
indicating that the region
+      // cannot be scheduled.
+      .onFailure {
+        case err: WorkflowRuntimeException =>
+          sendToClient(FatalError(err, err.relatedWorkerId))
+        case other =>
+          sendToClient(FatalError(other, None))
+      }
+    EmptyReturn()
+  }
+
+}
diff --git 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/promisehandlers/PortCompletedHandler.scala
 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/promisehandlers/PortCompletedHandler.scala
index 810c098c41..1dfb4b15d7 100644
--- 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/promisehandlers/PortCompletedHandler.scala
+++ 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/promisehandlers/PortCompletedHandler.scala
@@ -20,14 +20,11 @@
 package org.apache.texera.amber.engine.architecture.controller.promisehandlers
 
 import com.twitter.util.Future
-import org.apache.texera.amber.core.WorkflowRuntimeException
 import org.apache.texera.amber.core.workflow.GlobalPortIdentity
-import org.apache.texera.amber.engine.architecture.controller.{
-  ControllerAsyncRPCHandlerInitializer,
-  FatalError
-}
+import 
org.apache.texera.amber.engine.architecture.controller.ControllerAsyncRPCHandlerInitializer
 import org.apache.texera.amber.engine.architecture.rpc.controlcommands.{
   AsyncRPCContext,
+  EmptyRequest,
   PortCompletedRequest,
   QueryStatisticsRequest,
   StatisticsUpdateTarget
@@ -81,16 +78,17 @@ trait PortCompletedHandler {
               else operatorExecution.isOutputPortCompleted(msg.portId)
 
             if (isPortCompleted) {
-              cp.workflowExecutionCoordinator
-                .coordinateRegionExecutors(cp.actorService)
-                // Since this message is sent from a worker, any exception 
from the above code will be returned to that worker.
-                // Additionally, a fatal error is sent to the client, 
indicating that the region cannot be scheduled.
-                .onFailure {
-                  case err: WorkflowRuntimeException =>
-                    sendToClient(FatalError(err, err.relatedWorkerId))
-                  case other =>
-                    sendToClient(FatalError(other, None))
-                }
+              // Advance region executions in a later control round instead of 
here. Advancing
+              // inline terminates the completed region and sends `EndWorker` 
to this very sender
+              // before this handler's own reply, on the same control channel 
— the worker would
+              // then process `EndWorker` with the reply still queued behind 
it and reject the
+              // termination (see `EndHandler`). A message the controller 
addresses to itself is
+              // transmitted and received before it is handled, so the advance 
lands behind the
+              // reply below.
+              controllerInterface.controllerInitiateAdvanceRegionExecutions(
+                EmptyRequest(),
+                CONTROLLER
+              )
             }
           case None => // currently "start" and "end" ports are not part of a 
region, thus no region can be found.
           // do nothing.
diff --git 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/scheduling/RegionExecutionCoordinator.scala
 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/scheduling/RegionExecutionCoordinator.scala
index bc790f3ce5..35e58eed7a 100644
--- 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/scheduling/RegionExecutionCoordinator.scala
+++ 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/scheduling/RegionExecutionCoordinator.scala
@@ -132,8 +132,16 @@ class RegionExecutionCoordinator(
     *
     * Additionally, this method will also terminate all the workers of this 
region:
     *
-    * 1.  An `EndWorker` control message is first sent to all the workers. 
This will be the last message each worker
-    * receives. We wait for all workers have replied to indicate they have 
finished processing all control messages.
+    * 1.  An `EndWorker` control message is first sent to all the workers. We 
wait for all workers to reply that they
+    * have finished processing all control messages; a worker that has not 
fails the request, and
+    * `terminateWorkersWithRetry` re-sends `EndWorker` after `killRetryDelay`.
+    *
+    * Because a worker rejects `EndWorker` while work is still queued for it, 
termination must not be triggered from
+    * inside the handler of a request sent by one of these workers — the 
`EndWorker` would be emitted before that
+    * handler's own reply and overtake it on their shared FIFO control 
channel. Such handlers therefore send themselves
+    * a `ControllerInitiateAdvanceRegionExecutions` instead of advancing 
inline, which defers the advance that reaches
+    * here to a later controller round (see `PortCompletedHandler`). That 
orders the replies already produced; for one
+    * the controller has not produced yet, `EndHandler` does not count a 
queued reply as work.
     *
     * 2. Only after all workers have processed all control messages do we send 
a `gracefulStop` (pekko message) to each
     * worker. JVM workers will be terminated by `gracefulStop`. Python proxy 
workes will also be terminated by
diff --git 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/scheduling/WorkflowExecutionCoordinator.scala
 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/scheduling/WorkflowExecutionCoordinator.scala
index deb753beb3..cd29f5732d 100644
--- 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/scheduling/WorkflowExecutionCoordinator.scala
+++ 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/scheduling/WorkflowExecutionCoordinator.scala
@@ -61,6 +61,10 @@ class WorkflowExecutionCoordinator(
     * in `Completed` status (phase).
     *
     * After the syncs, if there are no running region(s), it will start new 
regions (if available).
+    *
+    * Callers handling a worker-initiated request must not call this directly; 
they send themselves a
+    * `ControllerInitiateAdvanceRegionExecutions` (see `PortCompletedHandler`) 
so the resulting
+    * `EndWorker` cannot overtake the reply that request still owes.
     */
   def coordinateRegionExecutors(actorService: PekkoActorService): Future[Unit] 
= {
     val unfinishedRegionCoordinators =
diff --git 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/EndHandler.scala
 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/EndHandler.scala
index 0504e66f52..1ae6efbeee 100644
--- 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/EndHandler.scala
+++ 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/EndHandler.scala
@@ -24,8 +24,15 @@ import 
org.apache.texera.amber.engine.architecture.rpc.controlcommands.{
   AsyncRPCContext,
   EmptyRequest
 }
-import 
org.apache.texera.amber.engine.architecture.rpc.controlreturns.EmptyReturn
+import org.apache.texera.amber.engine.architecture.rpc.controlreturns.{
+  EmptyReturn,
+  ReturnInvocation
+}
 import 
org.apache.texera.amber.engine.architecture.worker.DataProcessorRPCHandlerInitializer
+import org.apache.texera.amber.engine.architecture.worker.WorkflowWorker.{
+  DPInputQueueElement,
+  FIFOMessageElement
+}
 
 /**
   * The EndWorker control messages is needed to ensure all the other control 
messages in a worker
@@ -36,21 +43,67 @@ trait EndHandler {
 
   /**
     * The response of endWorker to the controller indicates that this worker 
has finished not only
-    * the data processing logic, but also , but also the processing of all the 
control messages.
+    * the data processing logic, but also the processing of all the control 
messages.
+    *
+    * A queued reply to one of this worker's own requests does not count: it 
carries no work (see
+    * `findUnprocessedWork`), and the controller cannot order all of its 
replies before `EndWorker`.
+    * The controller defers region advancement to a later control round 
precisely so that
+    * `EndWorker` follows the replies it owes (see `PortCompletedHandler`), 
but that only orders
+    * requests it has already handled. A request of this worker's that is 
still queued at the
+    * controller when the deferred advance runs — `workerExecutionCompleted`, 
which the worker
+    * emits right after its last `portCompleted` — is replied to afterwards, 
and the controller
+    * picks its input channels out of a `HashMap` 
(`NetworkInputGateway.tryPickControlChannel`), so
+    * there is no cross-channel order to rely on.
     */
   override def endWorker(
       request: EmptyRequest,
       ctx: AsyncRPCContext
   ): Future[EmptyReturn] = {
-    // Ensure this is really the last message.
-    if (!dp.inputManager.inputMessageQueue.isEmpty) {
+    // Ensure this is really the last message that asks this worker to do 
anything.
+    val pendingWork = findUnprocessedWork
+    if (pendingWork.isDefined) {
       logger.warn(
-        s"Received EndHandler before all messages are processed. Unprocessed 
messages: " +
-          s"${dp.inputManager.inputMessageQueue.peek()}"
+        s"Received EndHandler before all messages are processed. Unprocessed 
message: " +
+          s"${describe(pendingWork.get)}"
       )
       return Future.exception(new IllegalStateException("worker still has 
unprocessed messages"))
     }
     // Now we can safely acknowledge that this worker can be terminated.
     EmptyReturn()
   }
+
+  /**
+    * The first queued element that represents work, if any.
+    *
+    * A `ReturnInvocation` is excluded: processing one only fulfills a promise 
for a request this
+    * worker already issued (`AmberProcessor.processDCM`), and every 
worker-to-controller call
+    * discards its future, so nothing is pending on it. Everything else — 
control invocations, data,
+    * embedded control messages, timer-based controls, actor commands — still 
blocks termination, so
+    * the controller retries and this worker drains it first.
+    *
+    * This is the worker's own arrival queue rather than its actor mailbox, 
and at termination it
+    * holds at most a couple of elements.
+    */
+  private def findUnprocessedWork: Option[DPInputQueueElement] = {
+    val iterator = dp.inputManager.inputMessageQueue.iterator()
+    while (iterator.hasNext) {
+      val element = iterator.next()
+      val isWork = element match {
+        case FIFOMessageElement(message) => 
!message.payload.isInstanceOf[ReturnInvocation]
+        case _                           => true
+      }
+      if (isWork) {
+        return Some(element)
+      }
+    }
+    None
+  }
+
+  /** Identifies a queued element without logging payload contents. */
+  private def describe(element: DPInputQueueElement): String =
+    element match {
+      case FIFOMessageElement(message) =>
+        s"${message.payload.getClass.getSimpleName} on ${message.channelId}"
+      case other => other.getClass.getSimpleName
+    }
 }
diff --git 
a/amber/src/test/scala/org/apache/texera/amber/engine/architecture/controller/promisehandlers/PortCompletedHandlerSpec.scala
 
b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/controller/promisehandlers/PortCompletedHandlerSpec.scala
new file mode 100644
index 0000000000..12baaf43cd
--- /dev/null
+++ 
b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/controller/promisehandlers/PortCompletedHandlerSpec.scala
@@ -0,0 +1,282 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.texera.amber.engine.architecture.controller.promisehandlers
+
+import org.apache.pekko.actor.ActorSystem
+import org.apache.pekko.testkit.{ImplicitSender, TestKit}
+import org.apache.texera.amber.core.virtualidentity.{ActorVirtualIdentity, 
ChannelIdentity}
+import org.apache.texera.amber.core.workflow.{GlobalPortIdentity, 
PortIdentity, WorkflowContext}
+import org.apache.texera.amber.engine.architecture.controller.{
+  ControllerConfig,
+  ControllerProcessor
+}
+import org.apache.texera.amber.engine.architecture.rpc.controlcommands.{
+  AsyncRPCContext,
+  PortCompletedRequest,
+  ControlInvocation => ControlInvocationPayload
+}
+import org.apache.texera.amber.engine.architecture.rpc.controlreturns.{
+  ControlError,
+  ControlReturn,
+  EmptyReturn,
+  ReturnInvocation
+}
+import 
org.apache.texera.amber.engine.architecture.rpc.controllerservice.ControllerServiceGrpc.{
+  METHOD_CONTROLLER_INITIATE_ADVANCE_REGION_EXECUTIONS,
+  METHOD_PORT_COMPLETED
+}
+import 
org.apache.texera.amber.engine.architecture.scheduling.RegionCoordinatorTestSupport.{
+  createSingleWorkerRegion,
+  createSourceOp,
+  createWorkerId,
+  seedReusableWorkerExecution
+}
+import org.apache.texera.amber.engine.architecture.scheduling.{
+  RegionCoordinatorTestSupport,
+  RegionIdentity,
+  Schedule
+}
+import org.apache.texera.amber.engine.common.AmberRuntime
+import org.apache.texera.amber.engine.common.ambermessage.WorkflowFIFOMessage
+import 
org.apache.texera.amber.engine.common.rpc.AsyncRPCClient.ControlInvocation
+import org.apache.texera.amber.engine.common.virtualidentity.util.CONTROLLER
+import org.scalatest.BeforeAndAfterAll
+import org.scalatest.flatspec.AnyFlatSpecLike
+
+import scala.collection.mutable
+
+/**
+  * `portCompleted` is the request whose handling completes a region: once the 
last port of a
+  * region is done, the region is terminated, which sends `EndWorker` to each 
of its workers.
+  *
+  * The reply to `portCompleted` and that `EndWorker` travel the same FIFO 
control channel to the
+  * sending worker, so the region advance must NOT happen inline in this 
handler — it would put
+  * `EndWorker` on the wire ahead of the reply, and the worker would then 
process `EndWorker` with
+  * the reply still queued behind it and reject the termination (#6891). The 
advance is instead
+  * requested as a controller-to-controller control message, which the 
controller can only
+  * process in a later round — by which time this round's reply has been sent.
+  */
+class PortCompletedHandlerSpec
+    extends TestKit(ActorSystem("PortCompletedHandlerSpec", 
AmberRuntime.pekkoConfig))
+    with ImplicitSender
+    with AnyFlatSpecLike
+    with BeforeAndAfterAll
+    with RegionCoordinatorTestSupport {
+
+  private val physicalOp = createSourceOp("port-completed-op")
+  private val workerId = createWorkerId(physicalOp)
+  private val outputPortId = PortIdentity()
+  private val portCompletedCommandId = 7L
+
+  override def afterAll(): Unit = {
+    TestKit.shutdownActorSystem(system)
+  }
+
+  /**
+    * A real `ControllerProcessor` with a single-region schedule already in 
flight: the region
+    * owns the port the worker will report, and its worker execution is 
pre-seeded so starting the
+    * region does not create real workers. `coordinateRegionExecutors` is 
called once to move the
+    * region into the executing set (its `startWorker` RPCs are captured and 
never answered, so
+    * the region stays executing); the messages it produced are then dropped 
so each test only
+    * observes what handling `portCompleted` emits.
+    */
+  private def controllerWithRegionInFlight()
+      : (ControllerProcessor, mutable.ListBuffer[WorkflowFIFOMessage]) = {
+    val captured = mutable.ListBuffer[WorkflowFIFOMessage]()
+    val cp = new ControllerProcessor(
+      new WorkflowContext(),
+      ControllerConfig(None, None, None, None),
+      CONTROLLER,
+      {
+        case Right(msg) => captured += msg
+        case Left(_)    => ()
+      }
+    )
+    val harness = createControllerHarness()
+    registerLiveWorker(harness.actorRefService, workerId)
+    cp.setupActorService(harness.actorService)
+    
cp.workflowExecutionCoordinator.setupActorRefService(harness.actorRefService)
+
+    val region = createSingleWorkerRegion(1, physicalOp, workerId).copy(
+      ports = Set(GlobalPortIdentity(physicalOp.id, outputPortId, input = 
false))
+    )
+    seedReusableWorkerExecution(cp.workflowExecution, seedRegionId = 101, 
physicalOp, workerId)
+    cp.workflowExecutionCoordinator.schedule = Schedule(Map(0 -> Set(region)))
+    cp.workflowExecutionCoordinator.coordinateRegionExecutors(cp.actorService)
+
+    captured.clear()
+    (cp, captured)
+  }
+
+  /** Deliver a worker's `portCompleted` through the real RPC server, as 
production does. */
+  private def receivePortCompleted(cp: ControllerProcessor, portId: 
PortIdentity): Unit = {
+    cp.processDCM(
+      ChannelIdentity(workerId, CONTROLLER, isControl = true),
+      ControlInvocation(
+        METHOD_PORT_COMPLETED,
+        PortCompletedRequest(portId, input = false),
+        AsyncRPCContext(workerId, CONTROLLER),
+        portCompletedCommandId
+      )
+    )
+  }
+
+  private def messagesTo(
+      captured: mutable.ListBuffer[WorkflowFIFOMessage],
+      receiver: ActorVirtualIdentity
+  ): Seq[WorkflowFIFOMessage] =
+    captured.filter(_.channelId.toWorkerId == receiver).toSeq
+
+  private def repliesTo(
+      captured: mutable.ListBuffer[WorkflowFIFOMessage],
+      receiver: ActorVirtualIdentity
+  ): Seq[ReturnInvocation] =
+    messagesTo(captured, receiver).collect {
+      case WorkflowFIFOMessage(_, _, ret: ReturnInvocation) =>
+        ret
+    }
+
+  private def selfInvocations(
+      captured: mutable.ListBuffer[WorkflowFIFOMessage]
+  ): Seq[ControlInvocationPayload] =
+    messagesTo(captured, CONTROLLER).collect {
+      case WorkflowFIFOMessage(_, _, inv: ControlInvocationPayload) => inv
+    }
+
+  /** The controller-addressed messages asking for region executions to be 
advanced. */
+  private def advanceRequestsIn(
+      captured: mutable.ListBuffer[WorkflowFIFOMessage]
+  ): Seq[ControlInvocationPayload] =
+    // The RPC proxy sends the reflected Java method name, which differs from 
the generated
+    // constant only in the leading case; the server matches 
case-insensitively too.
+    selfInvocations(captured).filter(
+      _.methodName.equalsIgnoreCase(
+        METHOD_CONTROLLER_INITIATE_ADVANCE_REGION_EXECUTIONS.getBareMethodName
+      )
+    )
+
+  /** Resolve the statistics query the handler awaits, so its continuation 
runs. */
+  private def resolveStatisticsQuery(
+      cp: ControllerProcessor,
+      captured: mutable.ListBuffer[WorkflowFIFOMessage],
+      returnValue: ControlReturn = EmptyReturn()
+  ): Unit = {
+    val statsCommandId = selfInvocations(captured).head.commandId
+    cp.asyncRPCClient.fulfillPromise(ReturnInvocation(statsCommandId, 
returnValue))
+  }
+
+  "PortCompletedHandler" should
+    "request the region advance as a separate control message instead of 
advancing inline" in {
+    val (cp, captured) = controllerWithRegionInFlight()
+
+    receivePortCompleted(cp, outputPortId)
+    resolveStatisticsQuery(cp, captured)
+
+    // The advance leaves as a controller-to-controller control message, which 
the controller
+    // can only process in a later round — after this round's reply below has 
been sent.
+    assert(advanceRequestsIn(captured).size == 1)
+    assert(
+      repliesTo(captured, workerId) == Seq(
+        ReturnInvocation(portCompletedCommandId, EmptyReturn())
+      )
+    )
+  }
+
+  it should "send nothing but the reply to the reporting worker while handling 
portCompleted" in {
+    val (cp, captured) = controllerWithRegionInFlight()
+
+    receivePortCompleted(cp, outputPortId)
+    resolveStatisticsQuery(cp, captured)
+
+    // Guards against a regression to inline advancing: an `EndWorker` (or any 
other control
+    // invocation) emitted to this worker in this round would be ordered ahead 
of the reply.
+    assert(messagesTo(captured, workerId).size == 1)
+    assert(
+      !captured.exists(msg =>
+        msg.channelId.toWorkerId == workerId && 
msg.payload.isInstanceOf[ControlInvocationPayload]
+      )
+    )
+  }
+
+  it should "mark the reported port completed before requesting the advance" 
in {
+    val (cp, captured) = controllerWithRegionInFlight()
+
+    receivePortCompleted(cp, outputPortId)
+    resolveStatisticsQuery(cp, captured)
+
+    // The advance runs in a later round, so the bookkeeping it depends on 
must already be
+    // recorded in the execution state by the time the request goes out.
+    val operatorExecution = cp.workflowExecution
+      .getRegionExecution(RegionIdentity(1))
+      .getOperatorExecution(physicalOp.id)
+    assert(operatorExecution.isOutputPortCompleted(outputPortId))
+    assert(advanceRequestsIn(captured).size == 1)
+  }
+
+  it should "not request an advance for a port that belongs to no executing 
region" in {
+    val (cp, captured) = controllerWithRegionInFlight()
+
+    // "start"/"end" ports are not part of any region, so no region resolves 
and nothing advances.
+    receivePortCompleted(cp, PortIdentity(9))
+    resolveStatisticsQuery(cp, captured)
+
+    assert(advanceRequestsIn(captured).isEmpty)
+    assert(
+      repliesTo(captured, workerId) == Seq(
+        ReturnInvocation(portCompletedCommandId, EmptyReturn())
+      )
+    )
+  }
+
+  it should "request the advance without waiting for the sender's 
workerExecutionCompleted" in {
+    val (cp, captured) = controllerWithRegionInFlight()
+
+    // The worker emits `workerExecutionCompleted` right after its last 
`portCompleted`
+    // (`OutputManager.finalizeOutput` appends FinalizePort before 
FinalizeExecutor). Region
+    // completion keys only on port completion, so booking the last port 
requests the advance
+    // while that request may still be queued at the controller — and the 
controller selects
+    // input channels out of a HashMap, so it may run the advance first and 
reply afterwards.
+    receivePortCompleted(cp, outputPortId)
+    resolveStatisticsQuery(cp, captured)
+
+    assert(advanceRequestsIn(captured).size == 1)
+    // Nothing here has replied to a `workerExecutionCompleted`, so the 
resulting `EndWorker` can
+    // legitimately reach the worker before that reply does. `EndHandler` 
closes this by not
+    // counting a queued reply as work — the controller cannot order it, so 
the worker tolerates
+    // it (see EndHandlerSpec, "reply successfully when only a controller 
reply is queued").
+    assert(repliesTo(captured, workerId).map(_.commandId) == 
Seq(portCompletedCommandId))
+  }
+
+  it should "return a failed reply to the worker when its bookkeeping fails" 
in {
+    val (cp, captured) = controllerWithRegionInFlight()
+    receivePortCompleted(cp, outputPortId)
+
+    // Failing the statistics query fails the handler's continuation. The 
reply is still chained
+    // on that continuation, so the error is reported back to the worker that 
sent
+    // `portCompleted` — the contract documented for this handler, which 
deferring the advance
+    // must not break.
+    resolveStatisticsQuery(cp, captured, ControlError.defaultInstance)
+
+    val reply = repliesTo(captured, workerId).head
+    assert(reply.commandId == portCompletedCommandId)
+    assert(reply.returnValue.isInstanceOf[ControlError])
+    assert(advanceRequestsIn(captured).isEmpty)
+  }
+}
diff --git 
a/amber/src/test/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/EndHandlerSpec.scala
 
b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/EndHandlerSpec.scala
index 90e8b817be..9051c06751 100644
--- 
a/amber/src/test/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/EndHandlerSpec.scala
+++ 
b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/EndHandlerSpec.scala
@@ -25,7 +25,10 @@ import 
org.apache.texera.amber.engine.architecture.rpc.controlcommands.{
   AsyncRPCContext,
   EmptyRequest
 }
-import 
org.apache.texera.amber.engine.architecture.rpc.controlreturns.EmptyReturn
+import org.apache.texera.amber.engine.architecture.rpc.controlreturns.{
+  EmptyReturn,
+  ReturnInvocation
+}
 import 
org.apache.texera.amber.engine.architecture.rpc.workerservice.WorkerServiceGrpc.METHOD_QUERY_STATISTICS
 import org.apache.texera.amber.engine.architecture.worker.WorkflowWorker.{
   ActorCommandElement,
@@ -110,4 +113,68 @@ class EndHandlerSpec extends AnyFlatSpec {
 
     assertEndWorkerFails(handler)
   }
+
+  /**
+    * A reply to one of this worker's own requests is not work, and the 
controller cannot order
+    * every reply it owes before `EndWorker`: it defers region advancement to 
a later control round
+    * so `EndWorker` follows the requests it has already handled, but a 
request still queued at the
+    * controller when that advance runs — `workerExecutionCompleted`, emitted 
right after the last
+    * `portCompleted` — is replied to afterwards, and the controller selects 
input channels out of
+    * a `HashMap`, so there is no cross-channel order. Such a reply must not 
block termination.
+    */
+  private def controllerReply(seq: Long, commandId: Long): DPInputQueueElement 
=
+    FIFOMessageElement(
+      WorkflowFIFOMessage(
+        ChannelIdentity(CONTROLLER, workerId, isControl = true),
+        seq,
+        ReturnInvocation(commandId, EmptyReturn())
+      )
+    )
+
+  it should "reply successfully when only a controller reply is queued" in {
+    val queue = new LinkedBlockingQueue[DPInputQueueElement]()
+    queue.put(controllerReply(seq = 0, commandId = 3))
+    val handler = createEndHandlerForQueue(queue)
+
+    assert(await(handler.endWorker(EmptyRequest(), rpcContext)) == 
EmptyReturn())
+    // The reply is only inspected, never consumed: the DP thread still 
fulfills its promise.
+    assert(queue.size() == 1)
+  }
+
+  it should "reply successfully when several controller replies are queued" in 
{
+    val queue = new LinkedBlockingQueue[DPInputQueueElement]()
+    queue.put(controllerReply(seq = 0, commandId = 3))
+    queue.put(controllerReply(seq = 1, commandId = 4))
+    val handler = createEndHandlerForQueue(queue)
+
+    assert(await(handler.endWorker(EmptyRequest(), rpcContext)) == 
EmptyReturn())
+    assert(queue.size() == 2)
+  }
+
+  it should "fail when work is queued behind a controller reply" in {
+    // The scan must not stop at the head: a reply followed by a real request 
is still work.
+    val queue = new LinkedBlockingQueue[DPInputQueueElement]()
+    queue.put(controllerReply(seq = 0, commandId = 3))
+    queue.put(
+      FIFOMessageElement(
+        WorkflowFIFOMessage(
+          ChannelIdentity(CONTROLLER, workerId, isControl = true),
+          1,
+          ControlInvocation(METHOD_QUERY_STATISTICS, EmptyRequest(), 
rpcContext, 5)
+        )
+      )
+    )
+    val handler = createEndHandlerForQueue(queue)
+
+    assertEndWorkerFails(handler)
+    assert(queue.size() == 2)
+  }
+
+  it should "not consume queued messages when it fails" in {
+    val queue = queueWithFifoControlMessage()
+    val handler = createEndHandlerForQueue(queue)
+
+    assertEndWorkerFails(handler)
+    assert(queue.size() == 1)
+  }
 }

Reply via email to