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 a9fa9bc091 fix(amber, v1.2): order worker state by version, not 
timestamp (#7106)
a9fa9bc091 is described below

commit a9fa9bc091e2a8a3861c0d05586c0ab5c5bece8d
Author: Yicong Huang <[email protected]>
AuthorDate: Thu Jul 30 02:30:23 2026 -0400

    fix(amber, v1.2): order worker state by version, not timestamp (#7106)
    
    ### What changes were proposed in this PR?
    
    Backport of #6011 to `release/v1.2`, cherry-picked from
    f5217504c3cfd8ac1d56f6617d7849895af17f59.
    
    Follows the Direct Backport Push convention; opened as a PR (rather than
    a direct push) as part of a backport-coverage audit for fixes merged to
    `main` since early June that were never labeled for backport.
    
    ### Any related issues, documentation, discussions?
    
    Backport of #6011. Originally linked #6010.
    
    ### How was this PR tested?
    
    Release-branch CI runs once the conflicts are resolved and this PR is
    marked ready for review. The cherry-pick **conflicted** and was
    committed with conflict markers.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Yes — backport prepared with Claude Code (mechanical cherry-pick;
    conflicts left as markers for the original author to resolve; the change
    itself is #6011 by its original author).
    
    Co-authored-by: Claude Fable 5 <[email protected]>
---
 .../engine/architecture/rpc/controlcommands.proto  |   2 +
 .../engine/architecture/rpc/controlreturns.proto   |   2 +
 .../engine/architecture/worker/statistics.proto    |   5 +-
 .../handlers/control/pause_worker_handler.py       |   4 +-
 .../handlers/control/query_statistics_handler.py   |   4 +-
 .../handlers/control/resume_worker_handler.py      |   4 +-
 .../handlers/control/start_worker_handler.py       |   3 +-
 .../core/architecture/managers/state_manager.py    |  26 ++++++
 .../controller/execution/ExecutionUtils.scala      |  12 ++-
 .../controller/promisehandlers/PauseHandler.scala  |   7 +-
 .../QueryWorkerStatisticsHandler.scala             |   4 +-
 .../controller/promisehandlers/ResumeHandler.scala |   2 +-
 .../WorkerStateUpdatedHandler.scala                |   2 +-
 .../deploysemantics/layer/WorkerExecution.scala    |  70 ++++++++------
 .../scheduling/RegionExecutionCoordinator.scala    |   9 +-
 .../engine/architecture/worker/DataProcessor.scala |   3 +-
 .../worker/promisehandlers/PauseHandler.scala      |   3 +-
 .../promisehandlers/QueryStatisticsHandler.scala   |   3 +-
 .../worker/promisehandlers/ResumeHandler.scala     |   3 +-
 .../worker/promisehandlers/StartHandler.scala      |   6 +-
 .../common/statetransition/StateManager.scala      |  20 ++++
 .../architecture/managers/test_state_manager.py    |  40 ++++++++
 .../test/python/core/runnables/test_main_loop.py   |  29 +++++-
 .../controller/execution/ExecutionUtilsSpec.scala  |  12 +--
 .../layer/WorkerExecutionSpec.scala                | 103 ++++++++++++++++-----
 .../scheduling/RegionCoordinatorTestSupport.scala  |   3 +-
 .../statetransition/WorkerStateManagerSpec.scala   |  41 ++++++++
 27 files changed, 333 insertions(+), 89 deletions(-)

diff --git 
a/amber/src/main/protobuf/org/apache/texera/amber/engine/architecture/rpc/controlcommands.proto
 
b/amber/src/main/protobuf/org/apache/texera/amber/engine/architecture/rpc/controlcommands.proto
index 1f55927e4a..b4913932a8 100644
--- 
a/amber/src/main/protobuf/org/apache/texera/amber/engine/architecture/rpc/controlcommands.proto
+++ 
b/amber/src/main/protobuf/org/apache/texera/amber/engine/architecture/rpc/controlcommands.proto
@@ -166,6 +166,8 @@ message PortCompletedRequest {
 
 message WorkerStateUpdatedRequest {
   worker.WorkerState state = 1 [(scalapb.field).no_box = true];
+  // Monotonic per-worker version of state, for causal ordering by the 
controller.
+  int64 state_version = 2;
 }
 
 message LinkWorkersRequest {
diff --git 
a/amber/src/main/protobuf/org/apache/texera/amber/engine/architecture/rpc/controlreturns.proto
 
b/amber/src/main/protobuf/org/apache/texera/amber/engine/architecture/rpc/controlreturns.proto
index 43613b5cfd..563c3ee1dd 100644
--- 
a/amber/src/main/protobuf/org/apache/texera/amber/engine/architecture/rpc/controlreturns.proto
+++ 
b/amber/src/main/protobuf/org/apache/texera/amber/engine/architecture/rpc/controlreturns.proto
@@ -134,6 +134,8 @@ message StartWorkflowResponse {
 
 message WorkerStateResponse {
   worker.WorkerState state = 1 [(scalapb.field).no_box = true];
+  // Monotonic per-worker version of state, for causal ordering by the 
controller.
+  int64 state_version = 2;
 }
 
 message WorkerMetricsResponse {
diff --git 
a/amber/src/main/protobuf/org/apache/texera/amber/engine/architecture/worker/statistics.proto
 
b/amber/src/main/protobuf/org/apache/texera/amber/engine/architecture/worker/statistics.proto
index 85d1fcf4aa..4f84dc085f 100644
--- 
a/amber/src/main/protobuf/org/apache/texera/amber/engine/architecture/worker/statistics.proto
+++ 
b/amber/src/main/protobuf/org/apache/texera/amber/engine/architecture/worker/statistics.proto
@@ -60,4 +60,7 @@ message WorkerStatistics {
 message WorkerMetrics {
   WorkerState worker_state = 1 [(scalapb.field).no_box = true];
   WorkerStatistics worker_statistics = 2 [(scalapb.field).no_box = true];
-}
\ No newline at end of file
+  // Monotonic per-worker version of worker_state, used by the controller to 
order
+  // state reports causally instead of by wall-clock timestamp. See 
WorkerStateManager.
+  int64 state_version = 3;
+}
diff --git 
a/amber/src/main/python/core/architecture/handlers/control/pause_worker_handler.py
 
b/amber/src/main/python/core/architecture/handlers/control/pause_worker_handler.py
index ef9188914e..6a4d9162a2 100644
--- 
a/amber/src/main/python/core/architecture/handlers/control/pause_worker_handler.py
+++ 
b/amber/src/main/python/core/architecture/handlers/control/pause_worker_handler.py
@@ -26,5 +26,5 @@ from proto.org.apache.texera.amber.engine.architecture.rpc 
import (
 class PauseWorkerHandler(ControlHandler):
     async def pause_worker(self, req: EmptyRequest) -> WorkerStateResponse:
         self.context.pause_manager.pause(PauseType.USER_PAUSE)
-        state = self.context.state_manager.get_current_state()
-        return WorkerStateResponse(state)
+        state, state_version = 
self.context.state_manager.get_state_with_version()
+        return WorkerStateResponse(state, state_version=state_version)
diff --git 
a/amber/src/main/python/core/architecture/handlers/control/query_statistics_handler.py
 
b/amber/src/main/python/core/architecture/handlers/control/query_statistics_handler.py
index b636249d71..b982626827 100644
--- 
a/amber/src/main/python/core/architecture/handlers/control/query_statistics_handler.py
+++ 
b/amber/src/main/python/core/architecture/handlers/control/query_statistics_handler.py
@@ -27,8 +27,10 @@ from 
proto.org.apache.texera.amber.engine.architecture.worker import (
 
 class QueryStatisticsHandler(ControlHandler):
     async def query_statistics(self, req: EmptyRequest) -> 
WorkerMetricsResponse:
+        state, state_version = 
self.context.state_manager.get_state_with_version()
         metrics = WorkerMetrics(
-            worker_state=self.context.state_manager.get_current_state(),
+            worker_state=state,
             worker_statistics=self.context.statistics_manager.get_statistics(),
+            state_version=state_version,
         )
         return WorkerMetricsResponse(metrics)
diff --git 
a/amber/src/main/python/core/architecture/handlers/control/resume_worker_handler.py
 
b/amber/src/main/python/core/architecture/handlers/control/resume_worker_handler.py
index 3ebaadb661..5dca1fdc28 100644
--- 
a/amber/src/main/python/core/architecture/handlers/control/resume_worker_handler.py
+++ 
b/amber/src/main/python/core/architecture/handlers/control/resume_worker_handler.py
@@ -26,5 +26,5 @@ from proto.org.apache.texera.amber.engine.architecture.rpc 
import (
 class ResumeWorkerHandler(ControlHandler):
     async def resume_worker(self, req: EmptyRequest) -> WorkerStateResponse:
         self.context.pause_manager.resume(PauseType.USER_PAUSE)
-        state = self.context.state_manager.get_current_state()
-        return WorkerStateResponse(state)
+        state, state_version = 
self.context.state_manager.get_state_with_version()
+        return WorkerStateResponse(state, state_version=state_version)
diff --git 
a/amber/src/main/python/core/architecture/handlers/control/start_worker_handler.py
 
b/amber/src/main/python/core/architecture/handlers/control/start_worker_handler.py
index bfa1556722..b1e66ef02f 100644
--- 
a/amber/src/main/python/core/architecture/handlers/control/start_worker_handler.py
+++ 
b/amber/src/main/python/core/architecture/handlers/control/start_worker_handler.py
@@ -97,4 +97,5 @@ class StartWorkerHandler(ControlHandler):
         elif self.context.input_manager.get_input_port_mat_reader_threads():
             self.context.input_manager.start_input_port_mat_reader_threads()
 
-        return 
WorkerStateResponse(self.context.state_manager.get_current_state())
+        state, state_version = 
self.context.state_manager.get_state_with_version()
+        return WorkerStateResponse(state, state_version=state_version)
diff --git a/amber/src/main/python/core/architecture/managers/state_manager.py 
b/amber/src/main/python/core/architecture/managers/state_manager.py
index e80b6d5fc4..7cd5a16e2d 100644
--- a/amber/src/main/python/core/architecture/managers/state_manager.py
+++ b/amber/src/main/python/core/architecture/managers/state_manager.py
@@ -37,6 +37,13 @@ class StateManager:
     def __init__(self, state_transition_graph: Dict[T, Set[T]], initial_state: 
T):
         self._state_transition_graph = state_transition_graph
         self._current_state: T = initial_state
+        # Monotonically increasing version, bumped on every successful 
transition.
+        # It is the state machine's logical clock: since a single owner drives 
all
+        # transitions, the version totally orders them causally. Reporting it
+        # alongside the state lets the controller reject stale state reports 
that
+        # arrive out of order, without comparing wall-clock timestamps across
+        # processes. Must mirror the Scala StateManager.
+        self._state_version: int = 0
 
     def assert_state(self, state: T) -> None:
         """
@@ -75,6 +82,7 @@ class StateManager:
             )
 
         self._current_state = state
+        self._state_version += 1
 
     def get_current_state(self) -> T:
         """
@@ -82,3 +90,21 @@ class StateManager:
         :return:
         """
         return self._current_state
+
+    def get_state_version(self) -> int:
+        """
+        Return the monotonic version of the current state.
+        :return:
+        """
+        return self._state_version
+
+    def get_state_with_version(self) -> Tuple[T, int]:
+        """
+        Return the current state together with its version as one pair, so a
+        report site cannot interleave a transition between reading the state 
and
+        reading the version. Every state-report site must use this instead of
+        separate get_current_state/get_state_version calls. Mirrors the Scala
+        StateManager's getStateWithVersion.
+        :return:
+        """
+        return self._current_state, self._state_version
diff --git 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/execution/ExecutionUtils.scala
 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/execution/ExecutionUtils.scala
index 7ee9bc0473..4dd161c484 100644
--- 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/execution/ExecutionUtils.scala
+++ 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/execution/ExecutionUtils.scala
@@ -91,10 +91,14 @@ object ExecutionUtils {
       readyState: T
   ): WorkflowAggregatedState = {
     states match {
-      case _ if states.isEmpty                      => 
WorkflowAggregatedState.UNINITIALIZED
-      case _ if states.forall(_ == completedState)  => 
WorkflowAggregatedState.COMPLETED
-      case _ if states.forall(_ == terminatedState) => 
WorkflowAggregatedState.COMPLETED
-      case _ if states.exists(_ == runningState)    => 
WorkflowAggregatedState.RUNNING
+      case _ if states.isEmpty => WorkflowAggregatedState.UNINITIALIZED
+      // Every state is terminal: COMPLETED reported by the worker itself, or 
TERMINATED
+      // stamped by controller-side teardown (which only runs after the region 
completed,
+      // and preserves already-COMPLETED workers). A mixed terminal set is 
therefore a
+      // routine end-of-region shape and means the execution is over.
+      case _ if states.forall(s => s == completedState || s == 
terminatedState) =>
+        WorkflowAggregatedState.COMPLETED
+      case _ if states.exists(_ == runningState) => 
WorkflowAggregatedState.RUNNING
       case _ =>
         val unCompletedStates = states.filter(_ != completedState)
         if (unCompletedStates.forall(_ == uninitializedState)) {
diff --git 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/promisehandlers/PauseHandler.scala
 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/promisehandlers/PauseHandler.scala
index 35a85f56ae..eb13d92a0d 100644
--- 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/promisehandlers/PauseHandler.scala
+++ 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/promisehandlers/PauseHandler.scala
@@ -68,13 +68,16 @@ trait PauseHandler {
                       // send a pause message
                       workerInterface.pauseWorker(EmptyRequest(), 
mkContext(worker)).flatMap {
                         resp =>
-                          workerExecution.update(System.nanoTime(), resp.state)
+                          workerExecution.updateState(resp.stateVersion, 
resp.state)
                           workerInterface
                             .queryStatistics(EmptyRequest(), mkContext(worker))
                             // get the stats and current input tuple from the 
worker
                             .map {
                               case WorkerMetricsResponse(metrics) =>
-                                workerExecution.update(System.nanoTime(), 
metrics.workerStatistics)
+                                workerExecution.updateStats(
+                                  System.nanoTime(),
+                                  metrics.workerStatistics
+                                )
                             }
                       }
                     }.toSeq
diff --git 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/promisehandlers/QueryWorkerStatisticsHandler.scala
 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/promisehandlers/QueryWorkerStatisticsHandler.scala
index 6551579f71..e437709b19 100644
--- 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/promisehandlers/QueryWorkerStatisticsHandler.scala
+++ 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/promisehandlers/QueryWorkerStatisticsHandler.scala
@@ -175,7 +175,9 @@ trait QueryWorkerStatisticsHandler {
     processLayers(layers).map { _ =>
       collectedResults.foreach {
         case (wExec, resp, timestamp) =>
-          wExec.update(timestamp, resp.metrics.workerState, 
resp.metrics.workerStatistics)
+          // State is ordered by the worker's logical version; stats by 
receipt time.
+          wExec.updateState(resp.metrics.stateVersion, 
resp.metrics.workerState)
+          wExec.updateStats(timestamp, resp.metrics.workerStatistics)
       }
       forwardStats(msg.updateTarget)
       // Record the completion timestamp before releasing the lock so that any 
timer
diff --git 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/promisehandlers/ResumeHandler.scala
 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/promisehandlers/ResumeHandler.scala
index c94ba91c20..f1c173b9ca 100644
--- 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/promisehandlers/ResumeHandler.scala
+++ 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/promisehandlers/ResumeHandler.scala
@@ -52,7 +52,7 @@ trait ResumeHandler {
               cp.workflowExecution
                 
.getLatestOperatorExecution(VirtualIdentityUtils.getPhysicalOpId(workerId))
                 .getWorkerExecution(workerId)
-                .update(System.nanoTime(), resp.state)
+                .updateState(resp.stateVersion, resp.state)
             }
           }
           .toSeq
diff --git 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/promisehandlers/WorkerStateUpdatedHandler.scala
 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/promisehandlers/WorkerStateUpdatedHandler.scala
index 5ee98a4918..7226d93e0c 100644
--- 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/promisehandlers/WorkerStateUpdatedHandler.scala
+++ 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/controller/promisehandlers/WorkerStateUpdatedHandler.scala
@@ -49,7 +49,7 @@ trait WorkerStateUpdatedHandler {
       .find(_.hasOperatorExecution(physicalOpId))
       .map(_.getOperatorExecution(physicalOpId))
       .foreach(operatorExecution =>
-        
operatorExecution.getWorkerExecution(ctx.sender).update(System.nanoTime(), 
msg.state)
+        
operatorExecution.getWorkerExecution(ctx.sender).updateState(msg.stateVersion, 
msg.state)
       )
     val stats = cp.workflowExecution.getAllRegionExecutionsStats
     sendToClient(ExecutionStatsUpdate(stats))
diff --git 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/deploysemantics/layer/WorkerExecution.scala
 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/deploysemantics/layer/WorkerExecution.scala
index 55e1e30918..4e46e8b124 100644
--- 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/deploysemantics/layer/WorkerExecution.scala
+++ 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/deploysemantics/layer/WorkerExecution.scala
@@ -21,7 +21,11 @@ package 
org.apache.texera.amber.engine.architecture.deploysemantics.layer
 
 import org.apache.texera.amber.core.workflow.PortIdentity
 import 
org.apache.texera.amber.engine.architecture.controller.execution.WorkerPortExecution
-import 
org.apache.texera.amber.engine.architecture.worker.statistics.WorkerState.UNINITIALIZED
+import 
org.apache.texera.amber.engine.architecture.worker.statistics.WorkerState.{
+  COMPLETED,
+  TERMINATED,
+  UNINITIALIZED
+}
 import 
org.apache.texera.amber.engine.architecture.worker.statistics.{WorkerState, 
WorkerStatistics}
 
 import scala.collection.mutable
@@ -37,50 +41,56 @@ case class WorkerExecution() extends Serializable {
   private var stats: WorkerStatistics = {
     WorkerStatistics(Seq.empty, Seq.empty, 0, 0, 0)
   }
-  private var lastUpdateTimeStamp = 0L
+  // Logical version of the last applied state, sourced from the worker's
+  // WorkerStateManager. Starts below any real version so the first report 
applies.
+  private var lastStateVersion = -1L
+  // Controller-side receipt time (System.nanoTime) of the last applied stats 
snapshot.
+  private var lastStatsTimeStamp = 0L
+
+  private def isTerminal(s: WorkerState): Boolean = s == COMPLETED || s == 
TERMINATED
 
   /**
-    * Updates both the worker state and statistics if the provided timestamp 
is newer
-    * than the last recorded update timestamp. This ensures that only the most 
recent
-    * data is reflected in the execution state.
+    * Applies a worker state report, ordered causally by the worker's monotonic
+    * `stateVersion` rather than by receipt time. A report is applied only 
when it
+    * is strictly newer than the last applied one, so a stale state that 
arrives late
+    * (e.g. the RUNNING snapshot carried by a slow startWorker response) 
cannot clobber
+    * a newer state. In addition, terminal states (COMPLETED/TERMINATED) are 
absorbing:
+    * once reached, no later report can move the worker out of them.
     *
-    * @param timeStamp the nanosecond-timestamp of this update
-    * @param state the new WorkerState to set
-    * @param stats the new WorkerStatistics to set
+    * @param stateVersion the worker-side monotonic version of this state
+    * @param newState the reported WorkerState
     */
-  def update(timeStamp: Long, state: WorkerState, stats: WorkerStatistics): 
Unit = {
-    if (this.lastUpdateTimeStamp < timeStamp) {
-      this.stats = stats
-      this.state = state
-      this.lastUpdateTimeStamp = timeStamp
+  def updateState(stateVersion: Long, newState: WorkerState): Unit = {
+    if (isTerminal(this.state)) {
+      return
+    }
+    if (this.lastStateVersion < stateVersion) {
+      this.state = newState
+      this.lastStateVersion = stateVersion
     }
   }
 
   /**
-    * Updates only the worker state if the provided timestamp is newer than the
-    * last recorded update timestamp.
-    *
-    * @param timeStamp the nanosecond-timestamp of this update
-    * @param state the new WorkerState to set
+    * Forces the worker into TERMINATED, e.g. when the controller kills a 
region. This
+    * still respects terminal-state absorption: a worker that already 
COMPLETED on its
+    * own is left as COMPLETED. Uses the maximum version so it wins over any 
in-flight
+    * non-terminal report for a worker that had not yet reached a terminal 
state.
     */
-  def update(timeStamp: Long, state: WorkerState): Unit = {
-    if (this.lastUpdateTimeStamp < timeStamp) {
-      this.state = state
-      this.lastUpdateTimeStamp = timeStamp
-    }
-  }
+  def forceTerminate(): Unit = updateState(Long.MaxValue, TERMINATED)
 
   /**
     * Updates only the worker statistics if the provided timestamp is newer 
than the
-    * last recorded update timestamp.
+    * last recorded stats timestamp. Stats are monotonic snapshots, so 
newest-wins by
+    * receipt time is sufficient (and necessary, since two snapshots taken 
within the
+    * same state share a state version).
     *
     * @param timeStamp the nanosecond-timestamp of this update
-    * @param stats the new WorkerStatistics to set
+    * @param newStats the new WorkerStatistics to set
     */
-  def update(timeStamp: Long, stats: WorkerStatistics): Unit = {
-    if (this.lastUpdateTimeStamp < timeStamp) {
-      this.stats = stats
-      this.lastUpdateTimeStamp = timeStamp
+  def updateStats(timeStamp: Long, newStats: WorkerStatistics): Unit = {
+    if (this.lastStatsTimeStamp < timeStamp) {
+      this.stats = newStats
+      this.lastStatsTimeStamp = timeStamp
     }
   }
 
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 35e58eed7a..510893e82c 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
@@ -51,7 +51,6 @@ import 
org.apache.texera.amber.engine.architecture.scheduling.config.{
   ResourceConfig
 }
 import 
org.apache.texera.amber.engine.architecture.sendsemantics.partitionings.Partitioning
-import 
org.apache.texera.amber.engine.architecture.worker.statistics.WorkerState
 import org.apache.texera.amber.engine.common.AmberLogging
 import org.apache.texera.amber.engine.common.FutureBijection._
 import org.apache.texera.amber.engine.common.rpc.AsyncRPCClient
@@ -219,7 +218,7 @@ class RegionExecutionCoordinator(
         regionExecution.getAllOperatorExecutions.foreach {
           case (_, opExec) =>
             opExec.getWorkerIds.foreach { workerId =>
-              opExec.getWorkerExecution(workerId).update(System.nanoTime(), 
WorkerState.TERMINATED)
+              opExec.getWorkerExecution(workerId).forceTerminate()
             }
         }
         Future.Unit // propagate success
@@ -587,12 +586,14 @@ class RegionExecutionCoordinator(
               asyncRPCClient.workerInterface
                 .startWorker(EmptyRequest(), 
asyncRPCClient.mkContext(workerId))
                 .map(resp =>
-                  // update worker state
+                  // Update worker state, ordered by the worker's logical 
state version
+                  // (not arrival time) so this RUNNING snapshot cannot 
clobber a later
+                  // COMPLETED if the response arrives after the worker has 
finished.
                   workflowExecution
                     .getRegionExecution(region.id)
                     .getOperatorExecution(opId)
                     .getWorkerExecution(workerId)
-                    .update(System.nanoTime(), resp.state)
+                    .updateState(resp.stateVersion, resp.state)
                 )
             }
         }
diff --git 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/DataProcessor.scala
 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/DataProcessor.scala
index 84f1e8ec65..2a51063d8e 100644
--- 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/DataProcessor.scala
+++ 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/DataProcessor.scala
@@ -212,8 +212,9 @@ class DataProcessor(
           READY,
           RUNNING,
           () => {
+            val (state, stateVersion) = stateManager.getStateWithVersion
             asyncRPCClient.controllerInterface.workerStateUpdated(
-              WorkerStateUpdatedRequest(stateManager.getCurrentState),
+              WorkerStateUpdatedRequest(state, stateVersion),
               asyncRPCClient.mkContext(CONTROLLER)
             )
           }
diff --git 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/PauseHandler.scala
 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/PauseHandler.scala
index cec7ca87e6..548d13c036 100644
--- 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/PauseHandler.scala
+++ 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/PauseHandler.scala
@@ -46,7 +46,8 @@ trait PauseHandler {
       dp.pauseManager.pause(UserPause)
       dp.stateManager.transitTo(PAUSED)
     }
-    WorkerStateResponse(dp.stateManager.getCurrentState)
+    val (state, stateVersion) = dp.stateManager.getStateWithVersion
+    WorkerStateResponse(state, stateVersion)
   }
 
 }
diff --git 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/QueryStatisticsHandler.scala
 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/QueryStatisticsHandler.scala
index 74d8d14faa..feafd74a69 100644
--- 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/QueryStatisticsHandler.scala
+++ 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/QueryStatisticsHandler.scala
@@ -35,7 +35,8 @@ trait QueryStatisticsHandler {
       request: EmptyRequest,
       ctx: AsyncRPCContext
   ): Future[WorkerMetricsResponse] = {
-    WorkerMetricsResponse(WorkerMetrics(dp.stateManager.getCurrentState, 
dp.collectStatistics()))
+    val (state, stateVersion) = dp.stateManager.getStateWithVersion
+    WorkerMetricsResponse(WorkerMetrics(state, dp.collectStatistics(), 
stateVersion))
   }
 
 }
diff --git 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/ResumeHandler.scala
 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/ResumeHandler.scala
index 434c50c914..0ce6e8d076 100644
--- 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/ResumeHandler.scala
+++ 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/ResumeHandler.scala
@@ -43,7 +43,8 @@ trait ResumeHandler {
       dp.stateManager.transitTo(RUNNING)
       dp.adaptiveBatchingMonitor.resumeAdaptiveBatching()
     }
-    WorkerStateResponse(dp.stateManager.getCurrentState)
+    val (state, stateVersion) = dp.stateManager.getStateWithVersion
+    WorkerStateResponse(state, stateVersion)
   }
 
 }
diff --git 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/StartHandler.scala
 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/StartHandler.scala
index 5d1bf8ccbd..727eb7d025 100644
--- 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/StartHandler.scala
+++ 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/StartHandler.scala
@@ -56,12 +56,14 @@ trait StartHandler {
       dp.inputGateway.getChannel(channelId).setPortId(PortIdentity())
       startChannel(request, ctx)
       endChannel(request, ctx)
-      WorkerStateResponse(dp.stateManager.getCurrentState)
+      val (state, stateVersion) = dp.stateManager.getStateWithVersion
+      WorkerStateResponse(state, stateVersion)
     } else if (dp.inputManager.getInputPortReaderThreads.nonEmpty) {
       // This means the worker should read from materialized storage for its 
input ports.
       // Start the reader threads
       dp.inputManager.startInputPortReaderThreads()
-      WorkerStateResponse(dp.stateManager.getCurrentState)
+      val (state, stateVersion) = dp.stateManager.getStateWithVersion
+      WorkerStateResponse(state, stateVersion)
     } else {
       throw new WorkflowRuntimeException(
         s"non-source worker $actorId received unexpected StartWorker!"
diff --git 
a/amber/src/main/scala/org/apache/texera/amber/engine/common/statetransition/StateManager.scala
 
b/amber/src/main/scala/org/apache/texera/amber/engine/common/statetransition/StateManager.scala
index 9bd951389e..dd30a9de8f 100644
--- 
a/amber/src/main/scala/org/apache/texera/amber/engine/common/statetransition/StateManager.scala
+++ 
b/amber/src/main/scala/org/apache/texera/amber/engine/common/statetransition/StateManager.scala
@@ -43,6 +43,25 @@ class StateManager[T](
 
   private var currentState: T = initialState
 
+  // Monotonically increasing version, bumped on every successful state 
transition.
+  // It is the state machine's logical clock: because a single owner drives all
+  // transitions, the version totally orders them in causal order. Reporting 
this
+  // version alongside the state lets remote observers (e.g. the controller) 
reject
+  // stale state reports that arrive out of order, without relying on 
wall-clock
+  // timestamps that cannot be compared across processes. Must mirror the 
Python
+  // StateManager 
(amber/src/main/python/core/architecture/managers/state_manager.py).
+  private var stateVersion: Long = 0L
+
+  def getStateVersion: Long = stateVersion
+
+  /**
+    * Returns the current state together with its version as one pair, so a 
report
+    * site cannot interleave a transition between reading the state and 
reading the
+    * version. Every state-report site must use this instead of separate
+    * getCurrentState/getStateVersion calls.
+    */
+  def getStateWithVersion: (T, Long) = (currentState, stateVersion)
+
   def assertState(state: T): Unit = {
     if (currentState != state) {
       throw InvalidStateException(
@@ -84,6 +103,7 @@ class StateManager[T](
       throw InvalidTransitionException(s"cannot transit from $currentState to 
$state", actorId)
     }
     currentState = state
+    stateVersion += 1
   }
 
 }
diff --git 
a/amber/src/test/python/core/architecture/managers/test_state_manager.py 
b/amber/src/test/python/core/architecture/managers/test_state_manager.py
index 464c64194f..902cdb7475 100644
--- a/amber/src/test/python/core/architecture/managers/test_state_manager.py
+++ b/amber/src/test/python/core/architecture/managers/test_state_manager.py
@@ -72,3 +72,43 @@ class TestStateManager:
         state_manager.transit_to(WorkerState.READY)
         state_manager.transit_to(WorkerState.COMPLETED)
         state_manager.assert_state(WorkerState.COMPLETED)
+
+    def test_state_version_starts_at_zero(self, state_manager):
+        assert state_manager.get_state_version() == 0
+
+    def test_state_version_bumps_on_every_successful_transition(self, 
state_manager):
+        # The controller relies on this monotonic version to order 
Python-worker
+        # state reports causally; without it, RUNNING -> PAUSED -> RUNNING 
during
+        # reconfiguration would be dropped as stale. Mirrors the Scala 
StateManager.
+        assert state_manager.get_state_version() == 0
+        state_manager.transit_to(WorkerState.READY)
+        assert state_manager.get_state_version() == 1
+        state_manager.transit_to(WorkerState.RUNNING)
+        assert state_manager.get_state_version() == 2
+        state_manager.transit_to(WorkerState.COMPLETED)
+        assert state_manager.get_state_version() == 3
+
+    def test_state_version_does_not_bump_on_noop_self_transition(self, 
state_manager):
+        state_manager.transit_to(WorkerState.READY)
+        before = state_manager.get_state_version()
+        state_manager.transit_to(WorkerState.READY)  # no-op
+        assert state_manager.get_state_version() == before
+
+    def test_state_version_does_not_bump_on_rejected_transition(self, 
state_manager):
+        # UNINITIALIZED -> RUNNING is illegal (must pass through READY).
+        with pytest.raises(InvalidTransitionException):
+            state_manager.transit_to(WorkerState.RUNNING)
+        assert state_manager.get_state_version() == 0
+
+    def test_get_state_with_version_returns_matching_pair(self, state_manager):
+        # Report sites read state and version through this single accessor so 
the
+        # pair can never come from two different transitions. Mirrors the Scala
+        # StateManager's getStateWithVersion.
+        assert state_manager.get_state_with_version() == (
+            WorkerState.UNINITIALIZED,
+            0,
+        )
+        state_manager.transit_to(WorkerState.READY)
+        assert state_manager.get_state_with_version() == (WorkerState.READY, 1)
+        state_manager.transit_to(WorkerState.RUNNING)
+        assert state_manager.get_state_with_version() == (WorkerState.RUNNING, 
2)
diff --git a/amber/src/test/python/core/runnables/test_main_loop.py 
b/amber/src/test/python/core/runnables/test_main_loop.py
index c32f45b888..772b000df5 100644
--- a/amber/src/test/python/core/runnables/test_main_loop.py
+++ b/amber/src/test/python/core/runnables/test_main_loop.py
@@ -666,6 +666,8 @@ class TestMainLoop:
         stats_invocation = elem.payload.return_invocation
         worker_metrics_response = 
stats_invocation.return_value.worker_metrics_response
         stats = worker_metrics_response.metrics.worker_statistics
+        # a missing/dropped version would echo through the read-back below; 
guard it
+        assert worker_metrics_response.metrics.state_version > 0
 
         metrics = WorkerMetrics(
             worker_state=WorkerState.RUNNING,
@@ -692,6 +694,9 @@ class TestMainLoop:
                 control_processing_time=stats.control_processing_time,
                 idle_time=stats.idle_time,
             ),
+            # version is the worker's logical state clock; read it from the 
actual
+            # report rather than pinning a brittle count (covered by 
StateManager tests).
+            state_version=worker_metrics_response.metrics.state_version,
         )
 
         assert elem == DCMElement(
@@ -1099,13 +1104,21 @@ class TestMainLoop:
         output_queue,
     ):
         input_queue.put(mock_pause)
-        assert output_queue.get() == DCMElement(
+        elem = output_queue.get()
+        # version is the worker's logical state clock; read it from the actual
+        # report rather than pinning a brittle count (covered by StateManager 
tests).
+        state_version = 
elem.payload.return_invocation.return_value.worker_state_response.state_version
+        # a missing/dropped version would echo through the read-back; guard it
+        assert state_version > 0
+        assert elem == DCMElement(
             tag=mock_control_output_channel,
             payload=DirectControlMessagePayloadV2(
                 return_invocation=ReturnInvocation(
                     command_id=command_sequence,
                     return_value=ControlReturn(
-                        
worker_state_response=WorkerStateResponse(WorkerState.PAUSED)
+                        worker_state_response=WorkerStateResponse(
+                            WorkerState.PAUSED, state_version=state_version
+                        )
                     ),
                 )
             ),
@@ -1120,13 +1133,21 @@ class TestMainLoop:
         output_queue,
     ):
         input_queue.put(mock_resume)
-        assert output_queue.get() == DCMElement(
+        elem = output_queue.get()
+        # version is the worker's logical state clock; read it from the actual
+        # report rather than pinning a brittle count (covered by StateManager 
tests).
+        state_version = 
elem.payload.return_invocation.return_value.worker_state_response.state_version
+        # a missing/dropped version would echo through the read-back; guard it
+        assert state_version > 0
+        assert elem == DCMElement(
             tag=mock_control_output_channel,
             payload=DirectControlMessagePayloadV2(
                 return_invocation=ReturnInvocation(
                     command_id=command_sequence,
                     return_value=ControlReturn(
-                        
worker_state_response=WorkerStateResponse(WorkerState.RUNNING)
+                        worker_state_response=WorkerStateResponse(
+                            WorkerState.RUNNING, state_version=state_version
+                        )
                     ),
                 )
             ),
diff --git 
a/amber/src/test/scala/org/apache/texera/amber/engine/architecture/controller/execution/ExecutionUtilsSpec.scala
 
b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/controller/execution/ExecutionUtilsSpec.scala
index cf07c22843..d9405dad41 100644
--- 
a/amber/src/test/scala/org/apache/texera/amber/engine/architecture/controller/execution/ExecutionUtilsSpec.scala
+++ 
b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/controller/execution/ExecutionUtilsSpec.scala
@@ -91,12 +91,12 @@ class ExecutionUtilsSpec extends AnyFlatSpec {
   // Anti / boundary cases — make sure unexpected inputs cannot smuggle in a 
wrong
   // state, and that branch precedence is what the contract claims.
 
-  it should "return UNKNOWN when completed and terminated are mixed (neither 
forall branch matches)" in {
-    // Both `forall(_ == completed)` and `forall(_ == terminated)` fail, no 
running
-    // sentinel is present, and the non-completed remainder is purely 
terminated —
-    // which is none of uninitialized / paused / ready, so the result must be
-    // UNKNOWN rather than COMPLETED.
-    assert(aggregate(Completed, Terminated) == WorkflowAggregatedState.UNKNOWN)
+  it should "return COMPLETED when completed and terminated are mixed (all 
states terminal)" in {
+    // Routine after region teardown: workers whose final COMPLETED report 
reached the
+    // controller stay COMPLETED (terminal absorption), while stragglers are 
stamped
+    // TERMINATED by forceTerminate. All workers being terminal means the 
execution is
+    // over, so the aggregate must be COMPLETED rather than UNKNOWN.
+    assert(aggregate(Completed, Terminated) == 
WorkflowAggregatedState.COMPLETED)
   }
 
   it should "give running precedence over completed and terminated" in {
diff --git 
a/amber/src/test/scala/org/apache/texera/amber/engine/architecture/deploysemantics/layer/WorkerExecutionSpec.scala
 
b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/deploysemantics/layer/WorkerExecutionSpec.scala
index 2c39954d04..e2ac39595d 100644
--- 
a/amber/src/test/scala/org/apache/texera/amber/engine/architecture/deploysemantics/layer/WorkerExecutionSpec.scala
+++ 
b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/deploysemantics/layer/WorkerExecutionSpec.scala
@@ -36,51 +36,110 @@ class WorkerExecutionSpec extends AnyFlatSpec {
     assert(we.getStats.controlProcessingTime == 0L)
   }
 
-  "WorkerExecution.update(state)" should "apply when the timestamp is newer" 
in {
+  // ---- state ordering by logical version ----
+
+  "WorkerExecution.updateState" should "apply a state with a newer version" in 
{
     val we = WorkerExecution()
-    we.update(timeStamp = 10L, state = WorkerState.RUNNING)
+    we.updateState(stateVersion = 1L, WorkerState.READY)
+    we.updateState(stateVersion = 2L, WorkerState.RUNNING)
     assert(we.getState == WorkerState.RUNNING)
   }
 
-  it should "ignore updates with a non-newer timestamp" in {
+  it should "apply the very first report (version 0)" in {
     val we = WorkerExecution()
-    we.update(timeStamp = 10L, state = WorkerState.RUNNING)
-    we.update(timeStamp = 10L, state = WorkerState.PAUSED) // not strictly 
newer
-    we.update(timeStamp = 5L, state = WorkerState.COMPLETED) // older
-    assert(we.getState == WorkerState.RUNNING)
+    we.updateState(stateVersion = 0L, WorkerState.READY)
+    assert(we.getState == WorkerState.READY)
   }
 
-  "WorkerExecution.update(state, stats)" should "update both atomically when 
newer" in {
+  it should "ignore a stale report whose version is lower (out-of-order 
arrival)" in {
     val we = WorkerExecution()
-    we.update(timeStamp = 10L, state = WorkerState.RUNNING, stats = stats(idle 
= 7L))
-    assert(we.getState == WorkerState.RUNNING)
-    assert(we.getStats.idleTime == 7L)
+    we.updateState(stateVersion = 5L, WorkerState.PAUSED)
+    we.updateState(stateVersion = 4L, WorkerState.RUNNING) // arrives late, 
older version
+    assert(we.getState == WorkerState.PAUSED)
   }
 
-  it should "ignore updates with a non-newer timestamp" in {
+  it should "ignore a report whose version is not strictly newer" in {
     val we = WorkerExecution()
-    we.update(timeStamp = 10L, state = WorkerState.RUNNING, stats = stats(idle 
= 7L))
-    we.update(timeStamp = 5L, state = WorkerState.COMPLETED, stats = 
stats(idle = 99L))
+    we.updateState(stateVersion = 3L, WorkerState.RUNNING)
+    we.updateState(stateVersion = 3L, WorkerState.PAUSED) // same version
     assert(we.getState == WorkerState.RUNNING)
-    assert(we.getStats.idleTime == 7L)
   }
 
-  "WorkerExecution.update(stats)" should "update only the stats when newer" in 
{
+  // ---- terminal-state absorption ----
+
+  it should "treat COMPLETED as absorbing even against a higher-version 
report" in {
+    val we = WorkerExecution()
+    we.updateState(stateVersion = 2L, WorkerState.COMPLETED)
+    we.updateState(stateVersion = 99L, WorkerState.RUNNING) // higher version, 
but illegal
+    assert(we.getState == WorkerState.COMPLETED)
+  }
+
+  it should "treat TERMINATED as absorbing" in {
+    val we = WorkerExecution()
+    we.updateState(stateVersion = 2L, WorkerState.TERMINATED)
+    we.updateState(stateVersion = 99L, WorkerState.RUNNING)
+    assert(we.getState == WorkerState.TERMINATED)
+  }
+
+  // Regression for issue #6010: a fast source's startWorker response carries 
a stale
+  // RUNNING snapshot that can reach the controller AFTER COMPLETED was 
recorded. With
+  // wall-clock ordering the late RUNNING won and the operator was stuck 
orange; with
+  // version ordering (and terminal absorption) COMPLETED must survive.
+  it should "not let a late startWorker RUNNING snapshot clobber COMPLETED 
(#6010)" in {
+    val we = WorkerExecution()
+    we.updateState(stateVersion = 1L, WorkerState.READY)
+    we.updateState(stateVersion = 3L, WorkerState.COMPLETED) // via completion 
stats query
+    we.updateState(stateVersion = 2L, WorkerState.RUNNING) // late startWorker 
response
+    assert(we.getState == WorkerState.COMPLETED)
+  }
+
+  // ---- forceTerminate ----
+
+  "WorkerExecution.forceTerminate" should "move a non-terminal worker to 
TERMINATED" in {
+    val we = WorkerExecution()
+    we.updateState(stateVersion = 2L, WorkerState.RUNNING)
+    we.forceTerminate()
+    assert(we.getState == WorkerState.TERMINATED)
+  }
+
+  it should "leave a worker that already COMPLETED as COMPLETED" in {
     val we = WorkerExecution()
-    we.update(timeStamp = 10L, state = WorkerState.RUNNING, stats = stats(idle 
= 7L))
-    we.update(timeStamp = 20L, stats = stats(idle = 42L))
+    we.updateState(stateVersion = 3L, WorkerState.COMPLETED)
+    we.forceTerminate()
+    assert(we.getState == WorkerState.COMPLETED)
+  }
+
+  // ---- stats ordering by timestamp ----
+
+  "WorkerExecution.updateStats" should "apply newer stats and keep state 
untouched" in {
+    val we = WorkerExecution()
+    we.updateState(stateVersion = 2L, WorkerState.RUNNING)
+    we.updateStats(timeStamp = 10L, stats(idle = 7L))
+    we.updateStats(timeStamp = 20L, stats(idle = 42L))
     assert(we.getState == WorkerState.RUNNING)
     assert(we.getStats.idleTime == 42L)
   }
 
-  it should "ignore stats updates with a non-newer timestamp" in {
+  it should "ignore stats with a non-newer timestamp" in {
     val we = WorkerExecution()
-    we.update(timeStamp = 20L, stats = stats(idle = 42L))
-    we.update(timeStamp = 20L, stats = stats(idle = 99L)) // not strictly newer
-    we.update(timeStamp = 5L, stats = stats(idle = 0L)) // older
+    we.updateStats(timeStamp = 20L, stats(idle = 42L))
+    we.updateStats(timeStamp = 20L, stats(idle = 99L)) // not strictly newer
+    we.updateStats(timeStamp = 5L, stats(idle = 0L)) // older
     assert(we.getStats.idleTime == 42L)
   }
 
+  it should "track state version and stats timestamp independently" in {
+    val we = WorkerExecution()
+    // A high stats timestamp must not block a later (higher-version) state 
update,
+    // and vice versa: the two orderings are independent.
+    we.updateStats(timeStamp = 1000L, stats(idle = 1L))
+    we.updateState(stateVersion = 1L, WorkerState.RUNNING)
+    assert(we.getState == WorkerState.RUNNING)
+    assert(we.getStats.idleTime == 1L)
+  }
+
+  // ---- port executions ----
+
   "WorkerExecution.getInputPortExecution" should "lazily create and reuse a 
port execution per port id" in {
     val we = WorkerExecution()
     val first = we.getInputPortExecution(PortIdentity(0))
diff --git 
a/amber/src/test/scala/org/apache/texera/amber/engine/architecture/scheduling/RegionCoordinatorTestSupport.scala
 
b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/scheduling/RegionCoordinatorTestSupport.scala
index 64d85972ff..a6ded77587 100644
--- 
a/amber/src/test/scala/org/apache/texera/amber/engine/architecture/scheduling/RegionCoordinatorTestSupport.scala
+++ 
b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/scheduling/RegionCoordinatorTestSupport.scala
@@ -140,7 +140,8 @@ object RegionCoordinatorTestSupport {
         case InitializeExecutor | OpenExecutor =>
           Some(EmptyReturn())
         case StartWorker =>
-          Some(WorkerStateResponse(WorkerState.RUNNING))
+          // RUNNING is the worker's 2nd transition (UNINITIALIZED -> READY -> 
RUNNING).
+          Some(WorkerStateResponse(WorkerState.RUNNING, stateVersion = 2L))
         case EndWorker =>
           endWorkerResponse(call)
         case other =>
diff --git 
a/amber/src/test/scala/org/apache/texera/amber/engine/common/statetransition/WorkerStateManagerSpec.scala
 
b/amber/src/test/scala/org/apache/texera/amber/engine/common/statetransition/WorkerStateManagerSpec.scala
index 751c029ee4..e4b6d375fc 100644
--- 
a/amber/src/test/scala/org/apache/texera/amber/engine/common/statetransition/WorkerStateManagerSpec.scala
+++ 
b/amber/src/test/scala/org/apache/texera/amber/engine/common/statetransition/WorkerStateManagerSpec.scala
@@ -128,4 +128,45 @@ class WorkerStateManagerSpec extends AnyFlatSpec {
       sm.transitTo(COMPLETED)
     }
   }
+
+  // -- State version (logical clock) --
+
+  it should "start the state version at 0" in {
+    assert(newManager(UNINITIALIZED).getStateVersion == 0L)
+  }
+
+  it should "bump the state version on every successful transition" in {
+    val sm = newManager(UNINITIALIZED)
+    assert(sm.getStateVersion == 0L)
+    sm.transitTo(READY)
+    assert(sm.getStateVersion == 1L)
+    sm.transitTo(RUNNING)
+    assert(sm.getStateVersion == 2L)
+    sm.transitTo(COMPLETED)
+    assert(sm.getStateVersion == 3L)
+  }
+
+  it should "not bump the state version on a no-op self-transition" in {
+    val sm = newManager(RUNNING)
+    val before = sm.getStateVersion
+    sm.transitTo(RUNNING) // no-op
+    assert(sm.getStateVersion == before)
+  }
+
+  it should "not bump the state version when a transition is rejected" in {
+    val sm = newManager(UNINITIALIZED)
+    intercept[InvalidTransitionException] {
+      sm.transitTo(RUNNING)
+    }
+    assert(sm.getStateVersion == 0L)
+  }
+
+  it should "return the state and its version as one pair via 
getStateWithVersion" in {
+    val sm = newManager(UNINITIALIZED)
+    assert(sm.getStateWithVersion == ((UNINITIALIZED, 0L)))
+    sm.transitTo(READY)
+    assert(sm.getStateWithVersion == ((READY, 1L)))
+    sm.transitTo(RUNNING)
+    assert(sm.getStateWithVersion == ((RUNNING, 2L)))
+  }
 }

Reply via email to