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


The following commit(s) were added to refs/heads/main by this push:
     new 12eaccbb24 test(amber): add unit test coverage for 
CoordinatorTimerService (#7312)
12eaccbb24 is described below

commit 12eaccbb24884fccf1dba26d15fff7ceb1db4aba
Author: Eugene Gu <[email protected]>
AuthorDate: Sun Aug 9 16:13:23 2026 -0700

    test(amber): add unit test coverage for CoordinatorTimerService (#7312)
    
    ### What changes were proposed in this PR?
    
    Adds `CoordinatorTimerServiceSpec` (new, 13 tests) for
    
`amber/src/main/scala/org/apache/texera/amber/engine/architecture/coordinator/CoordinatorTimerService.scala`,
    which had no spec.
    
    No production code is changed.
    
    The spec substitutes a `PekkoActorService` that records
    `sendToSelfWithFixedDelay` requests instead of registering real
    repeating timers, and returns a fresh recording `Cancellable` per call.
    A real `ActorContext` comes from a minimal Pekko TestKit actor, since
    `PekkoActorService` dereferences `self`/`dispatcher` eagerly and there
    is no Mockito in the amber test tree. Every timing decision the class
    makes is taken synchronously at the `enable`/`disable` call, so the
    suite needs no real timers and no sleeps.
    
    Covered:
    
    - **Scheduling decision** — an unconfigured interval schedules nothing;
    a configured one schedules exactly once with initial delay 0, the
    configured period, and a `ControlInvocation` of
    `METHOD_COORDINATOR_INITIATE_QUERY_STATISTICS` carrying
    `QueryStatisticsRequest(Seq.empty, target)` and `AsyncRPCContext(SELF,
    SELF)`.
    - **The two entry points do not cross** — `enableStatusUpdate` uses
    `statusUpdateIntervalMs` with `UI_ONLY`,
    `enableRuntimeStatisticsCollection` uses
    `runtimeStatisticsPersistenceIntervalMs` with `PERSISTENCE_ONLY`,
    asserted separately and with distinct intervals; an asymmetric config
    (only one interval set) leaves the other timer unscheduled.
    - **Idempotence** — a second `enable` while the timer runs adds no
    schedule and keeps the same `Cancellable` instance.
    - **Disable** — cancels the handle and resets it to `None`, including
    when `cancel()` returns `false` (the return value is discarded by
    `disableTimer`); a second disable and a disable of a never-enabled timer
    are both no-ops.
    - **Restart and lifecycle** — a disabled timer can be enabled again, for
    both timers; a full pause-resume cycle re-schedules both with their own
    target and interval.
    - **Independence** — disabling one timer leaves the other's handle
    installed and un-cancelled, in both directions.
    
    ### Any related issues, documentation, discussions?
    
    Closes #7311.
    
    ### How was this PR tested?
    
    ```
    sbt "WorkflowExecutionService/testOnly 
org.apache.texera.amber.engine.architecture.coordinator.CoordinatorTimerServiceSpec"
    # 13 tests, all passed (~0.6 s)
    
    sbt "WorkflowExecutionService/testOnly 
org.apache.texera.amber.engine.architecture.coordinator.*"
    # 18 suites, 146 tests, all passed — the new top-level test helpers do not 
clash with the neighboring specs
    
    sbt "WorkflowExecutionService/Test/scalafmtCheck"
    # 173 sources, clean
    ```
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Co-authored by: Claude Code (Fable 5)
---
 .../coordinator/CoordinatorTimerServiceSpec.scala  | 429 +++++++++++++++++++++
 1 file changed, 429 insertions(+)

diff --git 
a/amber/src/test/scala/org/apache/texera/amber/engine/architecture/coordinator/CoordinatorTimerServiceSpec.scala
 
b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/coordinator/CoordinatorTimerServiceSpec.scala
new file mode 100644
index 0000000000..37c07592aa
--- /dev/null
+++ 
b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/coordinator/CoordinatorTimerServiceSpec.scala
@@ -0,0 +1,429 @@
+/*
+ * 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.coordinator
+
+import org.apache.pekko.actor.{Actor, ActorContext, ActorSystem, Cancellable, 
Props}
+import org.apache.pekko.testkit.{TestActorRef, TestKit}
+import org.apache.texera.amber.core.virtualidentity.ActorVirtualIdentity
+import org.apache.texera.amber.engine.architecture.common.PekkoActorService
+import org.apache.texera.amber.engine.architecture.rpc.controlcommands.{
+  AsyncRPCContext,
+  ControlInvocation,
+  QueryStatisticsRequest,
+  StatisticsUpdateTarget
+}
+import 
org.apache.texera.amber.engine.architecture.rpc.coordinatorservice.CoordinatorServiceGrpc.METHOD_COORDINATOR_INITIATE_QUERY_STATISTICS
+import org.apache.texera.amber.engine.common.rpc.AsyncRPCClient
+import org.apache.texera.amber.engine.common.virtualidentity.util.SELF
+import org.scalatest.BeforeAndAfterAll
+import org.scalatest.flatspec.AnyFlatSpecLike
+
+import scala.collection.mutable
+import scala.concurrent.duration.{DurationInt, FiniteDuration}
+
+/**
+  * Unit tests for [[CoordinatorTimerService]].
+  *
+  * There is no Mockito in the amber test tree, so instead of mocking 
`ActorContext`
+  * we obtain a real one from Pekko TestKit: a minimal [[TimerCtxHolder]] 
actor is
+  * spawned via `TestActorRef`, and `underlyingActor.context` supplies the live
+  * context that `PekkoActorService` eagerly dereferences (self / dispatcher).
+  *
+  * `sendToSelfWithFixedDelay` is overridden to capture the (initialDelay, 
delay,
+  * message) triple instead of registering a real repeating timer, and each 
capture
+  * returns a fresh [[RecordingCancellable]] so the tests can assert 
cancellation
+  * and the independence of the two timer handles deterministically, with no 
real
+  * scheduling and no sleeps.
+  */
+class CoordinatorTimerServiceSpec
+    extends TestKit(ActorSystem("CoordinatorTimerServiceSpec"))
+    with AnyFlatSpecLike
+    with BeforeAndAfterAll {
+
+  override def afterAll(): Unit = {
+    TestKit.shutdownActorSystem(system)
+  }
+
+  private val actorId: ActorVirtualIdentity = 
ActorVirtualIdentity("timer-test-coordinator")
+
+  private val statusIntervalMs: Long = 1000L
+  private val runtimeIntervalMs: Long = 3000L
+
+  // Each service instance needs its own live ActorContext; spawn a fresh 
holder
+  // actor (unique name) and hand back its context.
+  private val ctxCounter = new java.util.concurrent.atomic.AtomicInteger(0)
+  private def freshContext(): ActorContext = {
+    val holder = TestActorRef[TimerCtxHolder](
+      Props(new TimerCtxHolder),
+      s"timer-ctx-holder-${ctxCounter.incrementAndGet()}"
+    )
+    holder.underlyingActor.context
+  }
+
+  private def newTimerService(
+      statusUpdateIntervalMs: Option[Long],
+      runtimeStatisticsPersistenceIntervalMs: Option[Long],
+      cancelResult: Boolean = true
+  ): (CoordinatorTimerService, CapturingSelfSchedulerService) = {
+    val actorService = new CapturingSelfSchedulerService(actorId, 
freshContext(), cancelResult)
+    val config = CoordinatorConfig(
+      statusUpdateIntervalMs,
+      runtimeStatisticsPersistenceIntervalMs,
+      stateRestoreConfOpt = None,
+      faultToleranceConfOpt = None
+    )
+    (new CoordinatorTimerService(config, actorService), actorService)
+  }
+
+  private def expectedInvocation(target: StatisticsUpdateTarget): 
ControlInvocation =
+    AsyncRPCClient.ControlInvocation(
+      METHOD_COORDINATOR_INITIATE_QUERY_STATISTICS,
+      QueryStatisticsRequest(Seq.empty, target),
+      AsyncRPCContext(SELF, SELF),
+      0
+    )
+
+  // 
---------------------------------------------------------------------------
+  // enable with unconfigured interval (CoordinatorTimerService line 47)
+  // 
---------------------------------------------------------------------------
+
+  "enableStatusUpdate / enableRuntimeStatisticsCollection" should
+    "schedule nothing when the corresponding interval is unconfigured" in {
+    val (service, actorService) = newTimerService(None, None)
+
+    service.enableStatusUpdate()
+    service.enableRuntimeStatisticsCollection()
+
+    assert(actorService.captured.isEmpty)
+    assert(service.statusUpdateAskHandle.isEmpty)
+    assert(service.runtimeStatisticsAskHandle.isEmpty)
+  }
+
+  // 
---------------------------------------------------------------------------
+  // enable with configured interval: full scheduling payload per entry point
+  // (CoordinatorTimerService lines 48-59, 74-88)
+  // 
---------------------------------------------------------------------------
+
+  "enableStatusUpdate" should
+    "schedule a UI_ONLY statistics query at the configured status interval" in 
{
+    val (service, actorService) = newTimerService(Some(statusIntervalMs), 
Some(runtimeIntervalMs))
+
+    service.enableStatusUpdate()
+
+    assert(actorService.captured.size == 1)
+    val capture = actorService.captured.head
+    assert(capture.initialDelay == 0.milliseconds)
+    assert(capture.delay == FiniteDuration(statusIntervalMs, "ms"))
+    // Assert the individual fields first for readable failures, then the whole
+    // message, so any newly-added field would still be pinned by the equality.
+    val invocation = capture.msg.asInstanceOf[ControlInvocation]
+    assert(invocation.methodName == 
METHOD_COORDINATOR_INITIATE_QUERY_STATISTICS.getBareMethodName)
+    assert(invocation.command == QueryStatisticsRequest(Seq.empty, 
StatisticsUpdateTarget.UI_ONLY))
+    assert(invocation.context == AsyncRPCContext(SELF, SELF))
+    assert(invocation.commandId == 0)
+    assert(invocation == expectedInvocation(StatisticsUpdateTarget.UI_ONLY))
+    assert(service.statusUpdateAskHandle.contains(capture.handle))
+    // The other entry point's handle must be untouched.
+    assert(service.runtimeStatisticsAskHandle.isEmpty)
+  }
+
+  "enableRuntimeStatisticsCollection" should
+    "schedule a PERSISTENCE_ONLY statistics query at the configured 
persistence interval" in {
+    val (service, actorService) = newTimerService(Some(statusIntervalMs), 
Some(runtimeIntervalMs))
+
+    service.enableRuntimeStatisticsCollection()
+
+    assert(actorService.captured.size == 1)
+    val capture = actorService.captured.head
+    assert(capture.initialDelay == 0.milliseconds)
+    assert(capture.delay == FiniteDuration(runtimeIntervalMs, "ms"))
+    val invocation = capture.msg.asInstanceOf[ControlInvocation]
+    assert(invocation.methodName == 
METHOD_COORDINATOR_INITIATE_QUERY_STATISTICS.getBareMethodName)
+    assert(
+      invocation.command ==
+        QueryStatisticsRequest(Seq.empty, 
StatisticsUpdateTarget.PERSISTENCE_ONLY)
+    )
+    assert(invocation.context == AsyncRPCContext(SELF, SELF))
+    assert(invocation.commandId == 0)
+    assert(invocation == 
expectedInvocation(StatisticsUpdateTarget.PERSISTENCE_ONLY))
+    assert(service.runtimeStatisticsAskHandle.contains(capture.handle))
+    assert(service.statusUpdateAskHandle.isEmpty)
+  }
+
+  // 
---------------------------------------------------------------------------
+  // asymmetric config: each enabler reads only its own interval field
+  // 
---------------------------------------------------------------------------
+
+  "enableStatusUpdate / enableRuntimeStatisticsCollection" should
+    "each read only their own interval when a single interval is configured" 
in {
+    val (statusOnlyService, statusOnlyActorService) = 
newTimerService(Some(statusIntervalMs), None)
+
+    statusOnlyService.enableStatusUpdate()
+    statusOnlyService.enableRuntimeStatisticsCollection()
+
+    assert(statusOnlyActorService.captured.size == 1)
+    val statusCapture = statusOnlyActorService.captured.head
+    assert(statusCapture.msg == 
expectedInvocation(StatisticsUpdateTarget.UI_ONLY))
+    
assert(statusOnlyService.statusUpdateAskHandle.contains(statusCapture.handle))
+    assert(statusOnlyService.runtimeStatisticsAskHandle.isEmpty)
+
+    // Mirror case: only the persistence interval is configured.
+    val (runtimeOnlyService, runtimeOnlyActorService) =
+      newTimerService(None, Some(runtimeIntervalMs))
+
+    runtimeOnlyService.enableStatusUpdate()
+    runtimeOnlyService.enableRuntimeStatisticsCollection()
+
+    assert(runtimeOnlyActorService.captured.size == 1)
+    val runtimeCapture = runtimeOnlyActorService.captured.head
+    assert(runtimeCapture.msg == 
expectedInvocation(StatisticsUpdateTarget.PERSISTENCE_ONLY))
+    
assert(runtimeOnlyService.runtimeStatisticsAskHandle.contains(runtimeCapture.handle))
+    assert(runtimeOnlyService.statusUpdateAskHandle.isEmpty)
+  }
+
+  // 
---------------------------------------------------------------------------
+  // idempotence while a timer is running (CoordinatorTimerService lines 47, 
60-62)
+  // 
---------------------------------------------------------------------------
+
+  "enableStatusUpdate / enableRuntimeStatisticsCollection" should
+    "be idempotent while their timer is already running" in {
+    val (service, actorService) = newTimerService(Some(statusIntervalMs), 
Some(runtimeIntervalMs))
+
+    service.enableStatusUpdate()
+    service.enableRuntimeStatisticsCollection()
+    val statusHandle = service.statusUpdateAskHandle.get
+    val runtimeHandle = service.runtimeStatisticsAskHandle.get
+
+    service.enableStatusUpdate()
+    service.enableRuntimeStatisticsCollection()
+
+    // No new schedule call, and the handles are still the very same instances.
+    assert(actorService.captured.size == 2)
+    assert(service.statusUpdateAskHandle.get eq statusHandle)
+    assert(service.runtimeStatisticsAskHandle.get eq runtimeHandle)
+  }
+
+  // 
---------------------------------------------------------------------------
+  // disable cancels and resets; a second disable is a no-op
+  // (CoordinatorTimerService lines 65-72, 90-96)
+  // 
---------------------------------------------------------------------------
+
+  "disableStatusUpdate / disableRuntimeStatisticsCollection" should
+    "cancel the running timer, reset the handle, and no-op when already 
disabled" in {
+    val (service, actorService) = newTimerService(Some(statusIntervalMs), 
Some(runtimeIntervalMs))
+
+    service.enableStatusUpdate()
+    service.enableRuntimeStatisticsCollection()
+    val statusHandle = actorService.captured.head.handle
+    val runtimeHandle = actorService.captured(1).handle
+
+    service.disableStatusUpdate()
+    service.disableRuntimeStatisticsCollection()
+
+    assert(statusHandle.cancelCount == 1)
+    assert(runtimeHandle.cancelCount == 1)
+    assert(service.statusUpdateAskHandle.isEmpty)
+    assert(service.runtimeStatisticsAskHandle.isEmpty)
+
+    // Disabling again must neither throw nor cancel a second time.
+    service.disableStatusUpdate()
+    service.disableRuntimeStatisticsCollection()
+    assert(statusHandle.cancelCount == 1)
+    assert(runtimeHandle.cancelCount == 1)
+  }
+
+  "disableStatusUpdate / disableRuntimeStatisticsCollection" should
+    "reset the handle even when cancel() returns false" in {
+    // disableTimer discards the Boolean returned by cancel() (line 67) and 
resets
+    // unconditionally; the reset must not depend on the cancellation 
succeeding.
+    val (service, actorService) =
+      newTimerService(Some(statusIntervalMs), Some(runtimeIntervalMs), 
cancelResult = false)
+
+    service.enableStatusUpdate()
+    service.enableRuntimeStatisticsCollection()
+    service.disableStatusUpdate()
+    service.disableRuntimeStatisticsCollection()
+
+    assert(actorService.captured.head.handle.cancelCount == 1)
+    assert(actorService.captured(1).handle.cancelCount == 1)
+    assert(service.statusUpdateAskHandle.isEmpty)
+    assert(service.runtimeStatisticsAskHandle.isEmpty)
+  }
+
+  "disableStatusUpdate / disableRuntimeStatisticsCollection" should
+    "be a no-op when the timer was never enabled" in {
+    val (service, actorService) = newTimerService(Some(statusIntervalMs), 
Some(runtimeIntervalMs))
+
+    service.disableStatusUpdate()
+    service.disableRuntimeStatisticsCollection()
+
+    assert(actorService.captured.isEmpty)
+    assert(service.statusUpdateAskHandle.isEmpty)
+    assert(service.runtimeStatisticsAskHandle.isEmpty)
+  }
+
+  // 
---------------------------------------------------------------------------
+  // restart after disable: the guard is on the handle, not on history
+  // 
---------------------------------------------------------------------------
+
+  "enableStatusUpdate" should "schedule a fresh timer after a disable" in {
+    val (service, actorService) = newTimerService(Some(statusIntervalMs), None)
+
+    service.enableStatusUpdate()
+    service.disableStatusUpdate()
+    service.enableStatusUpdate()
+
+    assert(actorService.captured.size == 2)
+    val secondHandle = actorService.captured(1).handle
+    assert(service.statusUpdateAskHandle.contains(secondHandle))
+    assert(!secondHandle.isCancelled)
+  }
+
+  "enableRuntimeStatisticsCollection" should "schedule a fresh timer after a 
disable" in {
+    val (service, actorService) = newTimerService(None, 
Some(runtimeIntervalMs))
+
+    service.enableRuntimeStatisticsCollection()
+    service.disableRuntimeStatisticsCollection()
+    service.enableRuntimeStatisticsCollection()
+
+    assert(actorService.captured.size == 2)
+    val secondCapture = actorService.captured(1)
+    assert(service.runtimeStatisticsAskHandle.contains(secondCapture.handle))
+    assert(!secondCapture.handle.isCancelled)
+    assert(secondCapture.msg == 
expectedInvocation(StatisticsUpdateTarget.PERSISTENCE_ONLY))
+  }
+
+  // 
---------------------------------------------------------------------------
+  // full pause-resume lifecycle: StartWorkflowHandler enables both timers,
+  // PauseHandler disables both, resume re-enables both
+  // 
---------------------------------------------------------------------------
+
+  "enableStatusUpdate / enableRuntimeStatisticsCollection" should
+    "re-schedule both timers with their own target and interval across a 
pause-resume cycle" in {
+    val (service, actorService) = newTimerService(Some(statusIntervalMs), 
Some(runtimeIntervalMs))
+
+    service.enableStatusUpdate()
+    service.enableRuntimeStatisticsCollection()
+    service.disableStatusUpdate()
+    service.disableRuntimeStatisticsCollection()
+    service.enableStatusUpdate()
+    service.enableRuntimeStatisticsCollection()
+
+    assert(actorService.captured.size == 4)
+    // The first-round handles were each cancelled exactly once by the pause.
+    assert(actorService.captured.head.handle.cancelCount == 1)
+    assert(actorService.captured(1).handle.cancelCount == 1)
+    // The second-round handles are installed and live.
+    val statusResume = actorService.captured(2)
+    val runtimeResume = actorService.captured(3)
+    assert(service.statusUpdateAskHandle.contains(statusResume.handle))
+    assert(service.runtimeStatisticsAskHandle.contains(runtimeResume.handle))
+    assert(!statusResume.handle.isCancelled)
+    assert(!runtimeResume.handle.isCancelled)
+    // The re-enabled timers still carry their own interval and statistics 
target.
+    assert(statusResume.delay == FiniteDuration(statusIntervalMs, "ms"))
+    assert(statusResume.msg == 
expectedInvocation(StatisticsUpdateTarget.UI_ONLY))
+    assert(runtimeResume.delay == FiniteDuration(runtimeIntervalMs, "ms"))
+    assert(runtimeResume.msg == 
expectedInvocation(StatisticsUpdateTarget.PERSISTENCE_ONLY))
+  }
+
+  // 
---------------------------------------------------------------------------
+  // the two handles are independent of each other
+  // 
---------------------------------------------------------------------------
+
+  "disableStatusUpdate" should "not affect the runtime statistics timer" in {
+    val (service, actorService) = newTimerService(Some(statusIntervalMs), 
Some(runtimeIntervalMs))
+
+    service.enableStatusUpdate()
+    service.enableRuntimeStatisticsCollection()
+    service.disableStatusUpdate()
+
+    val runtimeHandle = actorService.captured(1).handle
+    assert(!runtimeHandle.isCancelled)
+    assert(service.runtimeStatisticsAskHandle.contains(runtimeHandle))
+    assert(service.statusUpdateAskHandle.isEmpty)
+  }
+
+  "disableRuntimeStatisticsCollection" should "not affect the status update 
timer" in {
+    val (service, actorService) = newTimerService(Some(statusIntervalMs), 
Some(runtimeIntervalMs))
+
+    service.enableStatusUpdate()
+    service.enableRuntimeStatisticsCollection()
+    service.disableRuntimeStatisticsCollection()
+
+    val statusHandle = actorService.captured.head.handle
+    assert(!statusHandle.isCancelled)
+    assert(service.statusUpdateAskHandle.contains(statusHandle))
+    assert(service.runtimeStatisticsAskHandle.isEmpty)
+  }
+}
+
+/** Minimal actor used only to obtain a real `ActorContext` from Pekko 
TestKit. */
+class TimerCtxHolder extends Actor {
+  override def receive: Receive = { case _ => () }
+}
+
+/**
+  * A [[Cancellable]] that records how many times it was cancelled. 
`cancelResult`
+  * is what `cancel()` reports back: Pekko's contract returns false when the 
task
+  * was already cancelled or the scheduler is shut down.
+  */
+class RecordingCancellable(cancelResult: Boolean = true) extends Cancellable {
+  var cancelCount: Int = 0
+
+  override def cancel(): Boolean = {
+    cancelCount += 1
+    cancelResult
+  }
+
+  override def isCancelled: Boolean = cancelCount > 0
+}
+
+/** One captured `sendToSelfWithFixedDelay` call and the fake handle it 
returned. */
+case class CapturedSchedule(
+    initialDelay: FiniteDuration,
+    delay: FiniteDuration,
+    msg: Any,
+    handle: RecordingCancellable
+)
+
+/**
+  * A [[PekkoActorService]] that captures self-send scheduling requests 
instead of
+  * registering real repeating timers, returning a fresh 
[[RecordingCancellable]]
+  * per call so cancellation of each timer can be asserted independently.
+  */
+class CapturingSelfSchedulerService(
+    vid: ActorVirtualIdentity,
+    ac: ActorContext,
+    cancelResult: Boolean = true
+) extends PekkoActorService(vid, ac) {
+
+  val captured: mutable.ArrayBuffer[CapturedSchedule] = mutable.ArrayBuffer()
+
+  override def sendToSelfWithFixedDelay(
+      initialDelay: FiniteDuration,
+      delay: FiniteDuration,
+      msg: Any
+  ): Cancellable = {
+    val handle = new RecordingCancellable(cancelResult)
+    captured += CapturedSchedule(initialDelay, delay, msg, handle)
+    handle
+  }
+}

Reply via email to