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 686aedba6c test(amber): cover the execution statistics service (#7544)
686aedba6c is described below

commit 686aedba6c41a95a509f44f88e0c336456aac382
Author: Xinyuan Lin <[email protected]>
AuthorDate: Tue Aug 11 00:14:53 2026 -0700

    test(amber): cover the execution statistics service (#7544)
    
    ### What changes were proposed in this PR?
    
    `ExecutionStatsService` had no spec and sat at **0% of its 80 lines**,
    while being the class that tells the frontend what an execution is
    doing: per-operator input/output metrics, worker assignment, elapsed
    duration, and the runtime statistics persisted for the dashboard's time
    series.
    
    Adds 12 tests driven through the three-argument constructor. The client
    is an `AmberClient` subclass that captures the registered callbacks, so
    the tests fire `ExecutionStatsUpdate`, `RuntimeStatisticsPersist`,
    `WorkerAssignmentUpdate`, `WorkflowRecoveryStatus`, `FatalError` and
    `ExecutionStateUpdate` directly; the state store is real, so events
    travel through the production diff handlers. This follows
    `ExecutionRuntimeServiceSpec` and `ExecutionConsoleServiceSpec`.
    
    The most valuable one is the carry-forward: an operator that stops
    reporting must still appear in the persisted statistics, or its row
    silently vanishes from the time series mid-execution.
    
    ### Verification
    
    19 mutations applied and reverted, production diff empty after each. All
    red, including the positional column layout of the persisted tuple, the
    commit guard holding statistics back until a terminal state,
    `client.shutdown()` on `FatalError`, and the wid/eid argument order into
    `updateRuntimeStatsUri`.
    
    Three assertions were **found to be vacuous in review and
    strengthened**, which is the part worth reading:
    
    | Weakness | Why it passed | Fix |
    |---|---|---|
    | running-duration arithmetic unpinned | the test asserted only
    `duration >= 1500`, so `currentTime - start` becoming `currentTime +
    start` (~111 years) still passed | bounded on both sides with a window
    captured around the update |
    | "every operator" observed one operator | the fixture reported a single
    operator, so `operatorInfo.collect` could be narrowed to
    `.take(1).collect` | a second operator with different numbers |
    | "publish nothing" checked the payload, not the event | the helper
    flattened the event's map, so a present-but-empty churn event to the
    websocket was invisible | collect by event type and assert empty,
    matching the two sibling tests |
    
    All three mutations are now red.
    
    ### Deliberately not included
    
    - The `catch` around `runtimeStatsWriter.close()` — Iceberg's close is
    idempotent, so nothing reaches it without injecting a throwing writer.
    - The `catch` in `storeRuntimeStatistics` — it runs on a private
    single-thread executor, which swallows the throwable, so no assertion
    could observe the mutation.
    - Three dead lines in `computeStatsDiff` (`defaultMetrics`, `newKeys`,
    and the `++ newKeys.map(...)` merge). `updatedLastMetrics` is read only
    at `oldKeys.map(key => key -> updatedLastMetrics(key))`, and
    `oldKeys`/`newKeys` are disjoint by construction, so the merged entries
    can never be selected — confirmed by replacing the whole expression with
    `lastPersistedMetrics` and seeing all 12 tests stay green. Reported
    rather than cemented; deleting beats testing.
    
    Two notes for reviewers. The spec uses distinct workflow/execution ids
    because the runtime-statistics URI derives from them and
    `createDocument` overrides an existing table — a default
    `WorkflowContext` collides with `DefaultCostEstimatorSpec`, and sbt runs
    amber suites in parallel in one JVM. And no temp Iceberg catalog is
    installed: `IcebergCatalogInstance.replaceInstance` is JVM-wide and the
    URI carries no warehouse, so installing one would hijack the catalog for
    every other amber suite.
    
    No production file is touched.
    
    ### Any related issues, documentation, discussions?
    
    Closes #7542
    
    ### How was this PR tested?
    
    ```
    STORAGE_ICEBERG_CATALOG_TYPE=postgres sbt 
"WorkflowExecutionService/testOnly 
org.apache.texera.web.service.ExecutionStatsServiceSpec"
    ```
    
    ```
    [info] Total number of tests run: 12
    [info] Tests: succeeded 12, failed 0, canceled 0, ignored 0, pending 0
    ```
    
    The env var matches what CI's unit job already sets
    (`.github/workflows/build.yml:293`); the committed default expects a
    Lakekeeper. `Test/scalafmtCheck` and `Test/scalafix --check` both pass.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 5)
---
 .../web/service/ExecutionStatsServiceSpec.scala    | 585 +++++++++++++++++++++
 1 file changed, 585 insertions(+)

diff --git 
a/amber/src/test/scala/org/apache/texera/web/service/ExecutionStatsServiceSpec.scala
 
b/amber/src/test/scala/org/apache/texera/web/service/ExecutionStatsServiceSpec.scala
new file mode 100644
index 0000000000..acc34e0e7c
--- /dev/null
+++ 
b/amber/src/test/scala/org/apache/texera/web/service/ExecutionStatsServiceSpec.scala
@@ -0,0 +1,585 @@
+/*
+ * 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 io.reactivex.rxjava3.disposables.Disposable
+import org.apache.pekko.actor.ActorSystem
+import org.apache.pekko.testkit.TestKit
+import org.apache.texera.amber.core.storage.model.VirtualDocument
+import org.apache.texera.amber.core.storage.{DocumentFactory, VFSURIFactory}
+import org.apache.texera.amber.core.tuple.Tuple
+import org.apache.texera.amber.core.virtualidentity.{
+  ActorVirtualIdentity,
+  ExecutionIdentity,
+  WorkflowIdentity
+}
+import org.apache.texera.amber.core.workflow.{PhysicalPlan, PortIdentity, 
WorkflowContext}
+import 
org.apache.texera.amber.core.workflowruntimestate.FatalErrorType.EXECUTION_FAILURE
+import org.apache.texera.amber.engine.architecture.coordinator.{
+  CoordinatorConfig,
+  ExecutionStateUpdate,
+  ExecutionStatsUpdate,
+  FatalError,
+  RuntimeStatisticsPersist,
+  WorkerAssignmentUpdate,
+  WorkflowRecoveryStatus
+}
+import 
org.apache.texera.amber.engine.architecture.rpc.controlreturns.WorkflowAggregatedState
+import 
org.apache.texera.amber.engine.architecture.rpc.controlreturns.WorkflowAggregatedState._
+import org.apache.texera.amber.engine.architecture.worker.statistics.{
+  PortTupleMetricsMapping,
+  TupleMetrics
+}
+import org.apache.texera.amber.engine.common.client.AmberClient
+import org.apache.texera.amber.engine.common.executionruntimestate.{
+  ExecutionStatsStore,
+  OperatorMetrics,
+  OperatorStatistics
+}
+import org.apache.texera.dao.MockTexeraDB
+import org.apache.texera.dao.jooq.generated.Tables.WORKFLOW_EXECUTIONS
+import org.apache.texera.dao.jooq.generated.tables.daos.{
+  UserDao,
+  WorkflowDao,
+  WorkflowExecutionsDao,
+  WorkflowVersionDao
+}
+import org.apache.texera.dao.jooq.generated.tables.pojos.{
+  User,
+  Workflow,
+  WorkflowExecutions,
+  WorkflowVersion
+}
+import org.apache.texera.web.model.websocket.event.{
+  ExecutionDurationUpdateEvent,
+  OperatorAggregatedMetrics,
+  OperatorStatisticsUpdateEvent,
+  TexeraWebSocketEvent,
+  WorkerAssignmentUpdateEvent
+}
+import org.apache.texera.web.storage.{ExecutionStateStore, StateStore}
+import org.scalatest.BeforeAndAfterAll
+import org.scalatest.flatspec.AnyFlatSpecLike
+import org.scalatest.matchers.should.Matchers
+
+import java.net.URI
+import java.sql.Timestamp
+import java.util.concurrent.ExecutorService
+import scala.collection.mutable
+import scala.collection.mutable.ListBuffer
+import scala.reflect.ClassTag
+
+/**
+  * The service class was entirely uncovered. It owns two independent outputs: 
the websocket
+  * diff handlers that tell the frontend what each operator is doing, and the 
runtime-statistics
+  * iceberg table that the dashboard replays after the execution is over.
+  *
+  * Everything here runs against an empty-plan AmberClient, so no engine is 
involved: the test
+  * fires the client events straight into the callbacks the service registered.
+  *
+  * Two things about the ids below are deliberate. They must not collide with 
the
+  * runtime-statistics URI of any other suite, because the constructor's 
`createDocument`
+  * truncates whatever table is already there and sbt runs amber suites in 
parallel in one JVM
+  * (DefaultCostEstimatorSpec owns wid 1 / eid 1). They must also differ from 
each other, so a
+  * URI built from the wrong identity is a visibly different URI.
+  *
+  * Not covered on purpose: the catch arm around `runtimeStatsWriter.close()` 
(iceberg's close is
+  * a repeatable buffer flush and does not throw), and the catch arm in 
`storeRuntimeStatistics`
+  * (it runs on the private persist executor, which swallows anything a test 
could observe).
+  */
+class ExecutionStatsServiceSpec
+    extends TestKit(ActorSystem("ExecutionStatsServiceSpec"))
+    with AnyFlatSpecLike
+    with Matchers
+    with BeforeAndAfterAll
+    with MockTexeraDB {
+
+  private val testUid: Integer = 9107
+  private val testWid: Integer = 9107
+  private val testEid: Integer = 9108
+
+  private val statsUri: URI = VFSURIFactory.createRuntimeStatisticsURI(
+    WorkflowIdentity(testWid.longValue()),
+    ExecutionIdentity(testEid.longValue())
+  )
+
+  override protected def beforeAll(): Unit = {
+    initializeDBAndReplaceDSLContext()
+
+    val user = new User
+    user.setUid(testUid)
+    user.setName("execution-stats-test-user")
+    user.setEmail(s"[email protected]")
+    new UserDao(getDSLContext.configuration()).insert(user)
+
+    val workflow = new Workflow
+    workflow.setWid(testWid)
+    workflow.setName(s"execution-stats-test-$testWid")
+    workflow.setContent("{}")
+    workflow.setDescription("")
+    workflow.setCreationTime(new Timestamp(System.currentTimeMillis()))
+    workflow.setLastModifiedTime(new Timestamp(System.currentTimeMillis()))
+    new WorkflowDao(getDSLContext.configuration()).insert(workflow)
+
+    val version = new WorkflowVersion
+    version.setWid(testWid)
+    version.setContent("{}")
+    version.setCreationTime(new Timestamp(System.currentTimeMillis()))
+    new WorkflowVersionDao(getDSLContext.configuration()).insert(version)
+
+    // The execution row is what the constructor stamps the statistics URI 
onto; it has to be
+    // reachable from testWid through workflow_version, which is how the 
update is scoped.
+    val execution = new WorkflowExecutions
+    execution.setEid(testEid)
+    execution.setVid(version.getVid)
+    execution.setUid(testUid)
+    execution.setStatus(0.toByte)
+    execution.setStartingTime(new Timestamp(System.currentTimeMillis()))
+    execution.setBookmarked(false)
+    execution.setName("execution-stats-test-execution")
+    execution.setEnvironmentVersion("test-env")
+    new WorkflowExecutionsDao(getDSLContext.configuration()).insert(execution)
+  }
+
+  override protected def afterAll(): Unit = {
+    try {
+      TestKit.shutdownActorSystem(system)
+      closeConnectionPool()
+    } finally super.afterAll()
+  }
+
+  /** Empty-plan client that captures every callback the service registers, 
keyed by event type. */
+  private final class TestAmberClient
+      extends AmberClient(
+        system,
+        new WorkflowContext(),
+        PhysicalPlan(Set.empty, Set.empty),
+        CoordinatorConfig(None, None, None, None),
+        _ => ()
+      ) {
+    var shutdownCount = 0
+    private val callbacks = mutable.Map.empty[Class[_], Any => Unit]
+
+    override def shutdown(): Unit = {
+      shutdownCount += 1
+      super.shutdown()
+    }
+
+    override def registerCallback[T](callback: T => Unit)(implicit ct: 
ClassTag[T]): Disposable = {
+      callbacks(ct.runtimeClass) = callback.asInstanceOf[Any => Unit]
+      Disposable.empty()
+    }
+
+    /** Delivers an engine event the way the client's observable would. */
+    def fire[T <: AnyRef](event: T)(implicit ct: ClassTag[T]): Unit =
+      callbacks(ct.runtimeClass)(event)
+
+    def dispose(): Unit = super.shutdown()
+  }
+
+  private final class Fixture(
+      val client: TestAmberClient,
+      val stateStore: ExecutionStateStore,
+      val service: ExecutionStatsService
+  ) {
+    def close(): Unit = {
+      service.unsubscribeAll()
+      client.dispose()
+    }
+  }
+
+  private def withService(body: Fixture => Unit): Unit = {
+    val client = new TestAmberClient
+    val stateStore = new ExecutionStateStore
+    val service = new ExecutionStatsService(
+      client,
+      stateStore,
+      new WorkflowContext(
+        workflowId = WorkflowIdentity(testWid.longValue()),
+        executionId = ExecutionIdentity(testEid.longValue())
+      )
+    )
+    val fixture = new Fixture(client, stateStore, service)
+    try body(fixture)
+    finally fixture.close()
+  }
+
+  /**
+    * One batch of websocket events is published per state update, so a test 
that cares about
+    * what a *second* update publishes has to subscribe before the first one. 
Subscribing later
+    * would also replay the diff between the default state and the current one 
as a batch.
+    */
+  private def recordBatches(
+      store: StateStore[ExecutionStatsStore]
+  )(body: => Unit): Seq[Iterable[TexeraWebSocketEvent]] = {
+    val batches = ListBuffer.empty[Iterable[TexeraWebSocketEvent]]
+    val subscription = store.getWebsocketEventObservable
+      .subscribe((batch: Iterable[TexeraWebSocketEvent]) => batches += batch)
+    try body
+    finally subscription.dispose()
+    batches.toSeq
+  }
+
+  private def metrics(
+      state: WorkflowAggregatedState,
+      inputs: Seq[(Int, Long, Long)] = Seq.empty,
+      outputs: Seq[(Int, Long, Long)] = Seq.empty,
+      numWorkers: Int = 1,
+      dataProcessingTime: Long = 0,
+      controlProcessingTime: Long = 0,
+      idleTime: Long = 0
+  ): OperatorMetrics = {
+    def portMetrics(ports: Seq[(Int, Long, Long)]): 
Seq[PortTupleMetricsMapping] =
+      ports.map {
+        case (port, count, size) =>
+          PortTupleMetricsMapping(PortIdentity(port), TupleMetrics(count, 
size))
+      }
+
+    OperatorMetrics(
+      state,
+      OperatorStatistics(
+        portMetrics(inputs),
+        portMetrics(outputs),
+        numWorkers,
+        dataProcessingTime,
+        controlProcessingTime,
+        idleTime
+      )
+    )
+  }
+
+  private def statisticsEvents(
+      batch: Iterable[TexeraWebSocketEvent]
+  ): Map[String, OperatorAggregatedMetrics] =
+    batch.collect { case e: OperatorStatisticsUpdateEvent => 
e.operatorStatistics }.flatten.toMap
+
+  /**
+    * The persist callback hands its work to a private single-threaded 
executor. A no-op task
+    * queued behind it can only run once that work is done, which is the 
barrier a test needs
+    * before it closes the writer and reads the table back.
+    */
+  private def awaitPersist(service: ExecutionStatsService): Unit = {
+    val field = 
classOf[ExecutionStatsService].getDeclaredField("metricsPersistThread")
+    field.setAccessible(true)
+    field.get(service).asInstanceOf[ExecutorService].submit((() => ()): 
Runnable).get()
+  }
+
+  /** Reads the committed statistics rows. Opening (never re-creating) the 
document is what makes
+    * this a read: `createDocument` truncates.
+    */
+  private def persistedRows(): Seq[Tuple] =
+    DocumentFactory
+      .openDocument(statsUri)
+      ._1
+      .asInstanceOf[VirtualDocument[Tuple]]
+      .get()
+      .toSeq
+
+  "the operator statistics callback" should
+    "publish per-port and aggregate metrics for every operator" in {
+    // Input and output are two symmetrical groups of fields on the same 
event; the frontend
+    // shows them as separate columns, so they are given distinct counts, 
sizes and port ids
+    // here to make crossing them visible.
+    withService { f =>
+      val batches = recordBatches(f.stateStore.statsStore) {
+        f.client.fire(
+          ExecutionStatsUpdate(
+            Map(
+              "opA" -> metrics(
+                RUNNING,
+                inputs = Seq((0, 3L, 30L), (1, 4L, 40L)),
+                outputs = Seq((2, 5L, 50L)),
+                numWorkers = 2,
+                dataProcessingTime = 11L,
+                controlProcessingTime = 12L,
+                idleTime = 13L
+              ),
+              // A second operator, because the handler maps over every 
reported operator and one
+              // operator cannot show that: `operatorInfo.collect` could be 
reduced to
+              // `operatorInfo.take(1).collect` and a single-operator fixture 
would not notice.
+              "opB" -> metrics(
+                COMPLETED,
+                inputs = Seq((0, 9L, 90L)),
+                outputs = Seq((1, 8L, 80L)),
+                numWorkers = 4,
+                dataProcessingTime = 21L,
+                controlProcessingTime = 22L,
+                idleTime = 23L
+              )
+            )
+          )
+        )
+      }
+
+      val published = statisticsEvents(batches.flatten)
+      published.keySet shouldBe Set("opA", "opB")
+      val opA = published("opA")
+      opA.operatorState shouldBe "Running"
+      opA.aggregatedInputRowCount shouldBe 7L
+      opA.aggregatedInputSize shouldBe 70L
+      opA.inputPortMetrics shouldBe Map("0" -> 3L, "1" -> 4L)
+      opA.aggregatedOutputRowCount shouldBe 5L
+      opA.aggregatedOutputSize shouldBe 50L
+      opA.outputPortMetrics shouldBe Map("2" -> 5L)
+      opA.numWorkers shouldBe 2L
+      opA.aggregatedDataProcessingTime shouldBe 11L
+      opA.aggregatedControlProcessingTime shouldBe 12L
+      opA.aggregatedIdleTime shouldBe 13L
+
+      val opB = published("opB")
+      opB.operatorState shouldBe "Completed"
+      opB.aggregatedInputRowCount shouldBe 9L
+      opB.aggregatedOutputRowCount shouldBe 8L
+      opB.numWorkers shouldBe 4L
+    }
+  }
+
+  it should "publish nothing when the reported metrics repeat" in {
+    // The frontend redraws the whole statistics panel per event, so 
re-announcing identical
+    // numbers is pure churn on every heartbeat of a long-running execution.
+    withService { f =>
+      val reported = Map("opA" -> metrics(RUNNING, inputs = Seq((0, 1L, 10L))))
+
+      val batches = recordBatches(f.stateStore.statsStore) {
+        f.client.fire(ExecutionStatsUpdate(reported))
+        // Same metrics, plus a change the duration handler reacts to, so a 
second batch is
+        // published at all -- it just must not carry a statistics event.
+        
f.stateStore.statsStore.updateState(_.withOperatorInfo(reported).withStartTimeStamp(1L))
+      }
+
+      batches should have size 2
+      statisticsEvents(batches.head).keySet shouldBe Set("opA")
+      // Collected by event TYPE, not by payload: flattening the payload map 
would treat a
+      // present-but-empty OperatorStatisticsUpdateEvent as "nothing 
published", when it is in fact
+      // churn on the websocket. This matches the two sibling "publish 
nothing" tests below.
+      batches.last.collect { case e: OperatorStatisticsUpdateEvent => e } 
shouldBe empty
+    }
+  }
+
+  "the worker assignment callback" should
+    "publish one event per operator carrying that operator's workers" in {
+    withService { f =>
+      val batches = recordBatches(f.stateStore.statsStore) {
+        f.client.fire(
+          WorkerAssignmentUpdate(
+            Map("opA" -> Seq("worker-a-0", "worker-a-1"), "opB" -> 
Seq("worker-b-0"))
+          )
+        )
+      }
+
+      val assignments = batches.flatten.collect {
+        case e: WorkerAssignmentUpdateEvent => e.operatorId -> e.workerIds
+      }
+      assignments.toMap shouldBe Map(
+        "opA" -> Seq("worker-a-0", "worker-a-1"),
+        "opB" -> Seq("worker-b-0")
+      )
+    }
+  }
+
+  it should "publish nothing when the assignment is unchanged" in {
+    withService { f =>
+      val batches = recordBatches(f.stateStore.statsStore) {
+        f.client.fire(WorkerAssignmentUpdate(Map("opA" -> Seq("worker-a-0"))))
+        // A later statistics update changes the state without touching the 
assignment.
+        f.client.fire(ExecutionStatsUpdate(Map("opA" -> metrics(RUNNING))))
+      }
+
+      batches should have size 2
+      batches.head.collect { case e: WorkerAssignmentUpdateEvent => e } should 
have size 1
+      batches.last.collect { case e: WorkerAssignmentUpdateEvent => e } 
shouldBe empty
+    }
+  }
+
+  "the execution duration handler" should "report the time elapsed so far 
while running" in {
+    withService { f =>
+      val startedAgo = 1500L
+      val before = System.currentTimeMillis()
+      val batches = recordBatches(f.stateStore.statsStore) {
+        f.stateStore.statsStore.updateState(
+          _.withStartTimeStamp(System.currentTimeMillis() - startedAgo)
+        )
+      }
+      val elapsedDuringUpdate = System.currentTimeMillis() - before
+
+      val durations = batches.flatten.collect { case e: 
ExecutionDurationUpdateEvent => e }
+      durations should have size 1
+      durations.head.isRunning shouldBe true
+      // The frontend keeps ticking the clock forward from this value, so it 
is measured
+      // against now rather than against the (still unset) end timestamp.
+      //
+      // Bounded on BOTH sides on purpose. A lower bound alone is satisfied by 
any arithmetic that
+      // grows the number, so `currentTime - startTimeStamp` could become 
`currentTime +
+      // startTimeStamp` -- roughly 111 years -- and still pass.
+      durations.head.duration should be >= startedAgo
+      durations.head.duration should be <= startedAgo + elapsedDuringUpdate
+    }
+  }
+
+  it should "report the total duration once the execution has ended" in {
+    withService { f =>
+      val batches = recordBatches(f.stateStore.statsStore) {
+        
f.stateStore.statsStore.updateState(_.withStartTimeStamp(1000L).withEndTimeStamp(4200L))
+      }
+
+      val durations = batches.flatten.collect { case e: 
ExecutionDurationUpdateEvent => e }
+      durations should have size 1
+      durations.head.duration shouldBe 3200L
+      durations.head.isRunning shouldBe false
+    }
+  }
+
+  it should "publish nothing when neither timestamp moved" in {
+    withService { f =>
+      val batches = recordBatches(f.stateStore.statsStore) {
+        f.stateStore.statsStore.updateState(_.withStartTimeStamp(1000L))
+        f.client.fire(ExecutionStatsUpdate(Map("opA" -> metrics(RUNNING))))
+      }
+
+      batches should have size 2
+      batches.head.collect { case e: ExecutionDurationUpdateEvent => e } 
should have size 1
+      batches.last.collect { case e: ExecutionDurationUpdateEvent => e } 
shouldBe empty
+    }
+  }
+
+  "the recovery status callback" should "mirror the reported flag onto the 
metadata store" in {
+    withService { f =>
+      f.client.fire(WorkflowRecoveryStatus(isRecovering = true))
+      f.stateStore.metadataStore.getState.isRecovering shouldBe true
+
+      // Both directions: the frontend leaves its recovery banner up until 
this flips back.
+      f.client.fire(WorkflowRecoveryStatus(isRecovering = false))
+      f.stateStore.metadataStore.getState.isRecovering shouldBe false
+    }
+  }
+
+  "a fatal error" should "stop the client and record the failure against the 
reporting operator" in {
+    withService { f =>
+      val cause = new RuntimeException("stats-service-boom")
+
+      f.client.fire(FatalError(cause, 
Some(ActorVirtualIdentity("Worker:WF1-udf1-main-0"))))
+
+      // The engine is already lost when it reports a fatal error; leaving the 
client running
+      // would keep the execution's actors alive behind a failed workflow.
+      f.client.shutdownCount shouldBe 1
+      f.stateStore.statsStore.getState.endTimeStamp should be > 0L
+      f.stateStore.metadataStore.getState.state shouldBe FAILED
+
+      val errors = f.stateStore.metadataStore.getState.fatalErrors
+      errors should have size 1
+      errors.head.`type` shouldBe EXECUTION_FAILURE
+      // The error panel groups by operator and links to the worker; the two 
are separate
+      // fields derived from the same actor id.
+      errors.head.operatorId shouldBe "udf1"
+      errors.head.workerId shouldBe "Worker:WF1-udf1-main-0"
+      errors.head.message shouldBe cause.toString
+      errors.head.details should include("stats-service-boom")
+    }
+  }
+
+  "the service constructor" should "stamp the runtime statistics URI onto the 
execution row" in {
+    // Spelled out rather than rebuilt from VFSURIFactory: the URI is what the 
dashboard later
+    // opens, so a wid/eid mix-up has to show up as a different string here.
+    getDSLContext
+      .update(WORKFLOW_EXECUTIONS)
+      .setNull(WORKFLOW_EXECUTIONS.RUNTIME_STATS_URI)
+      .where(WORKFLOW_EXECUTIONS.EID.eq(testEid))
+      .execute()
+
+    withService { _ =>
+      getDSLContext
+        .select(WORKFLOW_EXECUTIONS.RUNTIME_STATS_URI)
+        .from(WORKFLOW_EXECUTIONS)
+        .where(WORKFLOW_EXECUTIONS.EID.eq(testEid))
+        .fetchOne(WORKFLOW_EXECUTIONS.RUNTIME_STATS_URI) shouldBe
+        s"vfs:///wid/$testWid/eid/$testEid/runtimestatistics"
+    }
+  }
+
+  "the persistence callback" should
+    "carry an operator that stopped reporting into the persisted statistics" 
in {
+    withService { f =>
+      val opBFinal = metrics(
+        COMPLETED,
+        inputs = Seq((0, 3L, 30L)),
+        outputs = Seq((1, 4L, 40L)),
+        numWorkers = 2,
+        dataProcessingTime = 7L,
+        controlProcessingTime = 8L,
+        idleTime = 9L
+      )
+
+      f.client.fire(
+        RuntimeStatisticsPersist(
+          Map("opA" -> metrics(RUNNING, inputs = Seq((0, 1L, 10L))), "opB" -> 
opBFinal)
+        )
+      )
+      awaitPersist(f.service)
+      // opB is gone from the second report, as happens once an operator 
completes and stops
+      // being polled.
+      f.client.fire(RuntimeStatisticsPersist(Map("opA" -> metrics(COMPLETED))))
+      awaitPersist(f.service)
+      f.client.fire(ExecutionStateUpdate(COMPLETED))
+
+      val rows = persistedRows()
+      rows.map(_.getField[String]("operatorId")) should
+        contain theSameElementsAs Seq("opA", "opA", "opB", "opB")
+
+      // The dashboard replays these rows as a per-operator time series, so an 
operator that
+      // goes quiet has to keep contributing its last known numbers instead of 
dropping out of
+      // the chart at that point.
+      val opBRows = rows.filter(_.getField[String]("operatorId") == "opB")
+      opBRows should have size 2
+      opBRows.foreach { row =>
+        row.getField[Long]("inputTupleCnt") shouldBe 3L
+        row.getField[Long]("inputTupleSize") shouldBe 30L
+        row.getField[Long]("outputTupleCnt") shouldBe 4L
+        row.getField[Long]("outputTupleSize") shouldBe 40L
+        row.getField[Long]("dataProcessingTime") shouldBe 7L
+        row.getField[Long]("controlProcessingTime") shouldBe 8L
+        row.getField[Long]("idleTime") shouldBe 9L
+        row.getField[Int]("numWorkers") shouldBe 2
+        // COMPLETED, as a status code -- the column the dashboard colours the 
row by.
+        row.getField[Int]("status") shouldBe 3
+      }
+    }
+  }
+
+  it should "hold the statistics back until the execution reaches a terminal 
state" in {
+    // All three terminal states are meant to trigger the commit, and an 
execution reaches only
+    // one of them, so each gets its own service -- and with it a fresh 
statistics table.
+    Seq(COMPLETED, FAILED, KILLED).foreach { terminalState =>
+      withClue(s"terminal state $terminalState: ") {
+        withService { f =>
+          f.client.fire(RuntimeStatisticsPersist(Map("opA" -> 
metrics(RUNNING))))
+          awaitPersist(f.service)
+
+          // Mid-execution state changes must not commit: the writer is closed 
once, and closing
+          // it early would strand every later statistics row in the buffer.
+          f.client.fire(ExecutionStateUpdate(RUNNING))
+          persistedRows() shouldBe empty
+
+          f.client.fire(ExecutionStateUpdate(terminalState))
+          persistedRows() should have size 1
+        }
+      }
+    }
+  }
+}

Reply via email to