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

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git

commit bead35a9b17bf1758d61bb64a63c90e49b2881ef
Author: Xinyuan Lin <[email protected]>
AuthorDate: Sat Aug 1 00:11:50 2026 -0700

    test(amber): cover execution runtime lifecycle (#7221)
    
    ### What changes were proposed in this PR?
    
    Adds focused execution-runtime lifecycle coverage for pause, resume,
    kill, completed callbacks, and configured or missing checkpoint storage.
    
    ### Any related issues, documentation, discussions?
    
    Closes #7218
    
    ### How was this PR tested?
    
    - `WorkflowExecutionService/testOnly
    org.apache.texera.web.service.ExecutionRuntimeServiceSpec` — 6 passed.
    - `WorkflowExecutionService/Test/scalafmtCheck`
    - `WorkflowExecutionService/Test/scalafix --check`
    
    Mutation proof (each production mutation was reverted):
    
    | Production mutation | Focused test result |
    | --- | --- |
    | Changed the pause transition from `PAUSING` to `RUNNING` | 5 passed, 1
    failed |
    | Removed `performReconfigurationOnResume()` from the resume path | 5
    passed, 1 failed |
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Codex (GPT-5)
    
    ---------
    
    Signed-off-by: Xinyuan Lin <[email protected]>
    Co-authored-by: Copilot Autofix powered by AI 
<[email protected]>
---
 .../web/service/ExecutionRuntimeServiceSpec.scala  | 257 +++++++++++++++++++++
 1 file changed, 257 insertions(+)

diff --git 
a/amber/src/test/scala/org/apache/texera/web/service/ExecutionRuntimeServiceSpec.scala
 
b/amber/src/test/scala/org/apache/texera/web/service/ExecutionRuntimeServiceSpec.scala
new file mode 100644
index 0000000000..c633584ae2
--- /dev/null
+++ 
b/amber/src/test/scala/org/apache/texera/web/service/ExecutionRuntimeServiceSpec.scala
@@ -0,0 +1,257 @@
+/*
+ * 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.web.service
+
+import com.twitter.util.{Future, Promise}
+import io.reactivex.rxjava3.disposables.Disposable
+import org.apache.pekko.actor.ActorSystem
+import org.apache.pekko.testkit.TestKit
+import org.apache.texera.amber.core.workflow.{PhysicalPlan, WorkflowContext}
+import 
org.apache.texera.amber.engine.architecture.coordinator.ExecutionStateUpdate
+import 
org.apache.texera.amber.engine.architecture.coordinator.CoordinatorConfig
+import org.apache.texera.amber.engine.architecture.rpc.controlcommands.{
+  EmptyRequest,
+  TakeGlobalCheckpointRequest
+}
+import org.apache.texera.amber.engine.architecture.rpc.controlreturns.{
+  EmptyReturn,
+  TakeGlobalCheckpointResponse
+}
+import 
org.apache.texera.amber.engine.architecture.rpc.controlreturns.WorkflowAggregatedState._
+import 
org.apache.texera.amber.engine.architecture.rpc.coordinatorservice.CoordinatorServiceFs2Grpc
+import 
org.apache.texera.amber.engine.architecture.worker.WorkflowWorker.FaultToleranceConfig
+import org.apache.texera.amber.engine.common.client.AmberClient
+import org.apache.texera.web.WebsocketInput
+import org.apache.texera.web.model.websocket.request.{
+  WorkflowCheckpointRequest,
+  WorkflowKillRequest,
+  WorkflowPauseRequest,
+  WorkflowResumeRequest
+}
+import org.apache.texera.web.storage.ExecutionStateStore
+import org.scalamock.scalatest.MockFactory
+import org.scalatest.BeforeAndAfterAll
+import org.scalatest.flatspec.AnyFlatSpecLike
+import org.scalatest.matchers.should.Matchers
+
+import java.net.URI
+import scala.collection.mutable.ListBuffer
+import scala.reflect.ClassTag
+
+class ExecutionRuntimeServiceSpec
+    extends TestKit(ActorSystem("ExecutionRuntimeServiceSpec"))
+    with AnyFlatSpecLike
+    with Matchers
+    with MockFactory
+    with BeforeAndAfterAll {
+
+  override def afterAll(): Unit = {
+    try TestKit.shutdownActorSystem(system)
+    finally super.afterAll()
+  }
+
+  /** Real empty-plan client for constructor compatibility, with lifecycle 
collaborators overridden below. */
+  private final class TestAmberClient(
+      override val coordinatorInterface: CoordinatorServiceFs2Grpc[Future, 
Unit]
+  ) extends AmberClient(
+        system,
+        new WorkflowContext(),
+        PhysicalPlan(Set.empty, Set.empty),
+        CoordinatorConfig(None, None, None, None),
+        _ => ()
+      ) {
+    var shutdownCount = 0
+    var stateCallback: ExecutionStateUpdate => Unit = _
+
+    override def shutdown(): Unit = {
+      shutdownCount += 1
+      super.shutdown()
+    }
+
+    override def registerCallback[T](callback: T => Unit)(implicit ct: 
ClassTag[T]): Disposable = {
+      if (ct.runtimeClass == classOf[ExecutionStateUpdate]) {
+        stateCallback = callback.asInstanceOf[ExecutionStateUpdate => Unit]
+      }
+      Disposable.empty()
+    }
+
+    def dispose(): Unit = super.shutdown()
+  }
+
+  /** Records resume wiring while skipping constructor-time client and 
workflow subscriptions. */
+  private final class RecordingReconfigurationService
+      extends ExecutionReconfigurationService(client = null, stateStore = 
null, workflow = null) {
+    var resumeCallCount = 0
+
+    override def performReconfigurationOnResume(): Unit = {
+      resumeCallCount += 1
+    }
+
+    override protected def registerWorkerCompletionCallback(): Unit = {}
+
+    override protected def registerCompletionDiffHandler(): Unit = {}
+  }
+
+  private final class Fixture(
+      val client: TestAmberClient,
+      val coordinator: CoordinatorServiceFs2Grpc[Future, Unit],
+      val stateStore: ExecutionStateStore,
+      val wsInput: WebsocketInput,
+      val reconfigurationService: RecordingReconfigurationService,
+      val runtime: ExecutionRuntimeService,
+      val errors: ListBuffer[Throwable]
+  ) {
+    def close(): Unit = {
+      runtime.unsubscribeAll()
+      client.dispose()
+    }
+  }
+
+  private def fixture(
+      logConf: Option[FaultToleranceConfig] = Some(
+        FaultToleranceConfig(new URI("file:///runtime-checkpoints/"))
+      )
+  ): Fixture = {
+    val coordinator = mock[CoordinatorServiceFs2Grpc[Future, Unit]]
+    val client = new TestAmberClient(coordinator)
+    val stateStore = new ExecutionStateStore
+    val errors = ListBuffer.empty[Throwable]
+    val wsInput = new WebsocketInput(errors += _)
+    val reconfigurationService = new RecordingReconfigurationService
+
+    val runtime = new ExecutionRuntimeService(
+      client,
+      stateStore,
+      wsInput,
+      reconfigurationService,
+      logConf,
+      workflowId = 7L,
+      emailNotificationEnabled = false,
+      userEmailOpt = None,
+      sessionUri = new URI("https://texera.example/session";)
+    )
+    new Fixture(
+      client,
+      coordinator,
+      stateStore,
+      wsInput,
+      reconfigurationService,
+      runtime,
+      errors
+    )
+  }
+
+  "ExecutionRuntimeService" should "mark the runtime as pausing before sending 
a pause command" in {
+    val f = fixture()
+    try {
+      (f.coordinator.pauseWorkflow _)
+        .expects(EmptyRequest(), ())
+        .onCall { (_: EmptyRequest, _: Unit) =>
+          f.stateStore.metadataStore.getState.state shouldBe PAUSING
+          Future.value(EmptyReturn())
+        }
+
+      f.wsInput.onNext(WorkflowPauseRequest(), None)
+
+      f.stateStore.metadataStore.getState.state shouldBe PAUSING
+      f.errors shouldBe empty
+    } finally f.close()
+  }
+
+  it should "resume pending reconfiguration and transition to running after 
the RPC succeeds" in {
+    val f = fixture()
+    try {
+      val resumeResult = Promise[EmptyReturn]()
+      (f.coordinator.resumeWorkflow _)
+        .expects(EmptyRequest(), ())
+        .onCall { (_: EmptyRequest, _: Unit) =>
+          f.reconfigurationService.resumeCallCount shouldBe 1
+          resumeResult
+        }
+
+      f.wsInput.onNext(WorkflowResumeRequest(), None)
+      f.stateStore.metadataStore.getState.state shouldBe RESUMING
+
+      resumeResult.setValue(EmptyReturn())
+      f.stateStore.metadataStore.getState.state shouldBe RUNNING
+      f.errors shouldBe empty
+    } finally f.close()
+  }
+
+  it should "kill the client and record a terminal timestamp when killed from 
the websocket" in {
+    val f = fixture()
+    try {
+      f.wsInput.onNext(WorkflowKillRequest(), None)
+
+      f.client.shutdownCount shouldBe 1
+      f.stateStore.metadataStore.getState.state shouldBe KILLED
+      f.stateStore.statsStore.getState.endTimeStamp should be > 0L
+      f.errors shouldBe empty
+    } finally f.close()
+  }
+
+  it should "dispatch a non-estimation checkpoint request below the configured 
log URI" in {
+    val f = fixture()
+    try {
+      var captured: TakeGlobalCheckpointRequest = null
+      (f.coordinator.takeGlobalCheckpoint _)
+        .expects(*, ())
+        .onCall { (request: TakeGlobalCheckpointRequest, _: Unit) =>
+          captured = request
+          Future.value(TakeGlobalCheckpointResponse(0L))
+        }
+
+      f.wsInput.onNext(WorkflowCheckpointRequest(), None)
+
+      captured should not be null
+      captured.estimationOnly shouldBe false
+      captured.checkpointId.id should startWith("Checkpoint_")
+      val destination = new URI(captured.destination)
+      destination.getScheme shouldBe "file"
+      destination shouldBe new URI("file:///runtime-checkpoints/").resolve(
+        captured.checkpointId.toString
+      )
+      f.errors shouldBe empty
+    } finally f.close()
+  }
+
+  it should "report a checkpoint request without fault-tolerance storage 
instead of dispatching it" in {
+    val f = fixture(logConf = None)
+    try {
+      f.wsInput.onNext(WorkflowCheckpointRequest(), None)
+
+      f.errors should have size 1
+      f.errors.head.getMessage should include("Fault tolerance log folder is 
not established")
+    } finally f.close()
+  }
+
+  it should "apply a captured completed callback, stop the client, and stamp 
the end time" in {
+    val f = fixture()
+    try {
+      f.client.stateCallback should not be null
+      f.client.stateCallback(ExecutionStateUpdate(COMPLETED))
+
+      f.client.shutdownCount shouldBe 1
+      f.stateStore.metadataStore.getState.state shouldBe COMPLETED
+      f.stateStore.statsStore.getState.endTimeStamp should be > 0L
+      f.errors shouldBe empty
+    } finally f.close()
+  }
+}

Reply via email to