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 226124bb66 fix(amber, operator): keep error information on three
failure paths (#7804)
226124bb66 is described below
commit 226124bb66491b2abccb02269cf23ebaf151bb84
Author: Xinyuan Lin <[email protected]>
AuthorDate: Thu Aug 20 19:46:24 2026 +0000
fix(amber, operator): keep error information on three failure paths (#7804)
### What changes were proposed in this PR?
Three error paths that silently lost the information they exist to
surface. All were characterized during earlier coverage work and
deliberately left unpinned so a fix would not have to fight a test
asserting the broken behaviour.
**1. `OPVersion.getVersion` no longer returns `null` when `git log`
fails.** The `GitAPIException` catch now stores the same `"N/A"`
sentinel its `NullPointerException` sibling already did, so the result
is non-null *and* memoized — previously the trailing
`opMap.get(operatorName)` handed back `null`, which propagated into
operator metadata (`LogicalOp.scala:462-466` →
`OperatorMetadataGenerator.scala:171`), and every later call re-ran the
failing `git log`. `printStackTrace()` is kept.
That `"N/A"` is the intended sentinel is corroborated downstream:
`agent-service/src/agent/util/workflow-utils.ts:193` already defends
with `?? "N/A"`.
**2. `InputManager.startInputPortReaderThreads` now passes the cause.**
It wrapped a start failure as `new RuntimeException(s"…:
${e.getMessage}")` without `e`, discarding the original stack trace. The
reader thread itself already does this correctly
(`InputPortMaterializationReaderThread.scala:147`).
**3. `PekkoActorRefMappingService.retrieveActorRef`'s catch no longer
re-reads the failing value.** It bound `e` but never used it, instead
reading `actorService.parent` again to name the parent in its warning —
so a **persistently** unreachable parent threw straight out of the
handler meant to contain it. The message now omits the parent and passes
`e` as the log throwable, matching house style
(`WorkflowActor.scala:144`, `SyncExecutionResource.scala:705`).
**Why not hoist the read instead:** capturing the parent ref before the
`try` was considered and rejected — the read *is* what throws, so
hoisting it would let the failure escape `retrieveActorRef` entirely
rather than fixing anything.
### All three are pinned, verified in both directions
The production files were reverted and restored to confirm each test
actually fails without its fix:
| Suite | production reverted | with fixes |
|---|---|---|
| `OPVersionSpec` | **9 passed, 2 failed** | **11 passed** |
| `InputManagerSpec` + `PekkoActorRefMappingServiceSpec` | **20 passed,
2 failed** | **22 passed** |
The before-state failures are the right ones:
- `null was not equal to "N/A"` (both new OPVersion cases)
- `null equaled null the original failure must be attached as the cause`
- `java.lang.IllegalStateException: parent is unreachable at …
PekkoActorRefMappingService.retrieveActorRef` — i.e. thrown *out of* the
handler, exactly the predicted failure mode
### On the tests
- **OPVersion**: two cases using the existing unborn-HEAD throwaway-repo
technique — the `"N/A"` fallback, and that it is *memoized* so a failing
`git log` is not retried.
- **InputManager**: a `NoOpReaderThread` subclass (no-op `run()`, so no
storage is touched) is started and joined, then installed into the
private map by reflection — the same technique as the existing
`OutputPortStorageWriterThreadSpec.installWriterThread`.
`Thread.start()` on a TERMINATED thread then throws
`IllegalThreadStateException` deterministically, and the test asserts
both the wrapping message and that `getCause` is that exception.
- **PekkoActorRefMappingService**: the new case sets the failure counter
to `Int.MaxValue` so *every* read throws, not just the first — the
existing single-failure test could not have caught this. It also asserts
the id remains askable afterwards, i.e. was not wrongly marked queried.
Three stale comments were updated rather than left contradicting the new
assertions: `OPVersionSpec`'s "Deliberately NOT asserted" paragraph
about the null, and the defect notes on the two amber specs.
### Verification
- `OPVersionSpec` 11/11; `InputManagerSpec` +
`PekkoActorRefMappingServiceSpec` +
`InputPortMaterializationReaderThreadSpec` 32/32 (the reader-thread spec
included as the nearest neighbour to the `InputManager` change).
- Specs were filtered deliberately rather than running the whole
`WorkflowOperator` module, because `FileScanSourceOpExecSpec` currently
aborts on Windows over a leaked handle — that is being fixed separately
in #7800.
- `scalafmtCheck`, `Test/scalafmtCheck` and `scalafixAll --check` pass
for both modules. One pre-existing scalafix *warning* in an untouched
file (`OutputManagerSpec.scala:59`, unused suppression) is unrelated.
### Any related issues, documentation, discussions?
Closes #7803
### How was this PR tested?
```
sbt "WorkflowOperator/testOnly
org.apache.texera.amber.operator.metadata.OPVersionSpec"
"WorkflowExecutionService/testOnly
org.apache.texera.amber.engine.architecture.messaginglayer.InputManagerSpec
org.apache.texera.amber.engine.architecture.common.PekkoActorRefMappingServiceSpec"
```
```
[info] Tests: succeeded 11, failed 0, canceled 0, ignored 0, pending 0
[info] Tests: succeeded 22, failed 0, canceled 0, ignored 0, pending 0
```
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 5)
---
.../common/PekkoActorRefMappingService.scala | 7 +-
.../architecture/messaginglayer/InputManager.scala | 3 +-
.../common/PekkoActorRefMappingServiceSpec.scala | 29 +++++++-
.../messaginglayer/InputManagerSpec.scala | 77 +++++++++++++++++++++-
.../texera/amber/operator/metadata/OPVersion.java | 4 ++
.../amber/operator/metadata/OPVersionSpec.scala | 46 +++++++++++--
6 files changed, 153 insertions(+), 13 deletions(-)
diff --git
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/common/PekkoActorRefMappingService.scala
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/common/PekkoActorRefMappingService.scala
index f3fa7f3b92..223ffdf8c6 100644
---
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/common/PekkoActorRefMappingService.scala
+++
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/common/PekkoActorRefMappingService.scala
@@ -112,8 +112,13 @@ class PekkoActorRefMappingService(actorService:
PekkoActorService) extends Amber
queriedActorVirtualIdentities.add(id)
} catch {
case e: Throwable =>
+ // Deliberately does not read `actorService.parent` again: that is
the value whose
+ // failure this handler exists to contain, so re-reading it to
name the parent ref
+ // makes a persistently unreachable parent throw straight out of
the catch block.
+ // The exception carries the detail (including which lookup failed
and why).
logger.warn(
- s"Failed to fetch actorRef for
${VirtualIdentityUtils.toShorterString(id)} parentRef = " + actorService.parent
+ s"Failed to fetch actorRef for
${VirtualIdentityUtils.toShorterString(id)} from parent",
+ e
)
}
}
diff --git
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/messaginglayer/InputManager.scala
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/messaginglayer/InputManager.scala
index 4e29782943..c017eb238b 100644
---
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/messaginglayer/InputManager.scala
+++
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/messaginglayer/InputManager.scala
@@ -105,7 +105,8 @@ class InputManager(
} catch {
case e: Exception =>
throw new RuntimeException(
- s"Error starting input port materialization reader thread:
${e.getMessage}"
+ s"Error starting input port materialization reader thread:
${e.getMessage}",
+ e
)
}
})
diff --git
a/amber/src/test/scala/org/apache/texera/amber/engine/architecture/common/PekkoActorRefMappingServiceSpec.scala
b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/common/PekkoActorRefMappingServiceSpec.scala
index d836008dac..db830f0b5e 100644
---
a/amber/src/test/scala/org/apache/texera/amber/engine/architecture/common/PekkoActorRefMappingServiceSpec.scala
+++
b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/common/PekkoActorRefMappingServiceSpec.scala
@@ -170,9 +170,7 @@ class PekkoActorRefMappingServiceSpec
val destination = ActorVirtualIdentity("unreachable-parent-destination")
val waiter = TestProbe()
// Pekko itself does not fail here -- `context.parent` reads a field and
`!` never throws -- so
- // the failure is injected, and only for the first read: the catch block's
own log line reads
- // `actorService.parent` a second time, which means a parent that kept
failing would throw out
- // of the handler that exists to contain it.
+ // the failure is injected, for the first read only.
val actorService = new FailingParentActorService(workerId,
newContext(parent))
val service = new PekkoActorRefMappingService(actorService)
actorService.failuresLeft = 1
@@ -190,6 +188,31 @@ class PekkoActorRefMappingServiceSpec
assert(parent.expectMsgType[GetActorRef].id == destination)
}
+
+ it should "contain a parent lookup that keeps failing, not just its first
failure" in {
+ val parent = TestProbe()
+ val destination =
ActorVirtualIdentity("persistently-unreachable-parent-destination")
+ val waiter = TestProbe()
+ val actorService = new FailingParentActorService(workerId,
newContext(parent))
+ val service = new PekkoActorRefMappingService(actorService)
+ // Every read of `parent` throws, which is the realistic shape of an
unreachable parent: the
+ // condition is a property of the actor, not of one attempt. A handler
that reads the failing
+ // value a second time (e.g. to name the parent ref in its own log line)
therefore throws out
+ // of the very try/catch that exists to contain the failure.
+ actorService.failuresLeft = Int.MaxValue
+
+ service.retrieveActorRef(destination, Set(waiter.ref))
+
+ parent.expectNoMessage(100.millis)
+ waiter.expectNoMessage(100.millis)
+
+ // Once the parent recovers, the id must still be askable -- the contained
failure may not have
+ // marked it queried.
+ actorService.failuresLeft = 0
+ service.retrieveActorRef(destination, Set(waiter.ref))
+
+ assert(parent.expectMsgType[GetActorRef].id == destination)
+ }
}
/** Minimal actor used only to obtain a live [[ActorContext]] from Pekko
TestKit. */
diff --git
a/amber/src/test/scala/org/apache/texera/amber/engine/architecture/messaginglayer/InputManagerSpec.scala
b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/messaginglayer/InputManagerSpec.scala
index bcaf91e97d..2beaf4a428 100644
---
a/amber/src/test/scala/org/apache/texera/amber/engine/architecture/messaginglayer/InputManagerSpec.scala
+++
b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/messaginglayer/InputManagerSpec.scala
@@ -22,12 +22,17 @@ package
org.apache.texera.amber.engine.architecture.messaginglayer
import org.apache.texera.amber.core.tuple.{Attribute, AttributeType, Schema,
Tuple}
import org.apache.texera.amber.core.virtualidentity.{ActorVirtualIdentity,
ChannelIdentity}
import org.apache.texera.amber.core.workflow.PortIdentity
-import
org.apache.texera.amber.engine.architecture.sendsemantics.partitionings.Partitioning
+import
org.apache.texera.amber.engine.architecture.sendsemantics.partitionings.{
+ BroadcastPartitioning,
+ Partitioning
+}
import
org.apache.texera.amber.engine.architecture.worker.WorkflowWorker.DPInputQueueElement
+import
org.apache.texera.amber.engine.architecture.worker.managers.InputPortMaterializationReaderThread
import org.scalatest.flatspec.AnyFlatSpec
import java.net.URI
import java.util.concurrent.LinkedBlockingQueue
+import scala.collection.mutable
class InputManagerSpec extends AnyFlatSpec {
@@ -221,4 +226,74 @@ class InputManagerSpec extends AnyFlatSpec {
mgr.startInputPortReaderThreads() // must not throw
succeed
}
+
+ //
---------------------------------------------------------------------------
+ // startInputPortReaderThreads — a start() that fails
+ //
---------------------------------------------------------------------------
+ //
+ // A reader thread whose body does nothing, so it can be driven to a
terminated
+ // state without opening any materialization storage. `InputManager` only
ever
+ // calls `start()` on it, and `Thread.start()` on a thread that is no longer
NEW
+ // raises `IllegalThreadStateException` — a real, deterministic start
failure with
+ // no timing window.
+ private class NoOpReaderThread
+ extends InputPortMaterializationReaderThread(
+ uri = new URI("file:///nowhere"),
+ inputMessageQueue = emptyQueue(),
+ workerActorId = actorId,
+ partitioning = BroadcastPartitioning(
+ batchSize = 1,
+ channels = Seq(channelId("upstream", "worker-1"))
+ )
+ ) {
+ override def run(): Unit = ()
+ }
+
+ /**
+ * Reaches into InputManager's private
inputPortMaterializationReaderThreads map, the
+ * way OutputPortStorageWriterThreadSpec does for OutputManager: the map is
only
+ * populated by `addPort`, which builds real reader threads from real URIs,
and the
+ * failure under test is in how `startInputPortReaderThreads` reports a
failed start,
+ * not in what the thread reads.
+ */
+ private def installReaderThreads(
+ manager: InputManager,
+ portId: PortIdentity,
+ threads: List[InputPortMaterializationReaderThread]
+ ): Unit = {
+ val field = classOf[InputManager]
+ .getDeclaredField("inputPortMaterializationReaderThreads")
+ field.setAccessible(true)
+ field
+ .get(manager)
+ .asInstanceOf[mutable.HashMap[PortIdentity,
List[InputPortMaterializationReaderThread]]]
+ .put(portId, threads)
+ }
+
+ "InputManager.startInputPortReaderThreads (failing start)" should
+ "wrap the failure with the original exception attached as its cause" in {
+ val mgr = freshManager
+ val alreadyStarted = new NoOpReaderThread
+ alreadyStarted.start()
+ alreadyStarted.join() // now TERMINATED, so a second start() is guaranteed
to fail
+ installReaderThreads(mgr, PortIdentity(0), List(alreadyStarted))
+
+ val thrown = intercept[RuntimeException] {
+ mgr.startInputPortReaderThreads()
+ }
+
+ // The wrapper, not the raw IllegalThreadStateException (which is itself a
+ // RuntimeException and would otherwise satisfy the intercept above).
+ assert(
+ thrown.getMessage.contains("Error starting input port materialization
reader thread"),
+ s"expected the wrapping message, got: ${thrown.getMessage}"
+ )
+ // Dropping the cause discards the only stack trace that says where the
start
+ // failed; the message alone names neither the port nor the failing frame.
+ assert(thrown.getCause != null, "the original failure must be attached as
the cause")
+ assert(
+ thrown.getCause.isInstanceOf[IllegalThreadStateException],
+ s"expected the start failure itself as the cause, got:
${thrown.getCause}"
+ )
+ }
}
diff --git
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/metadata/OPVersion.java
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/metadata/OPVersion.java
index acf8828d56..bd0daf0fd8 100644
---
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/metadata/OPVersion.java
+++
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/metadata/OPVersion.java
@@ -46,6 +46,10 @@ public class OPVersion {
opMap.put(operatorName, version);
} catch (GitAPIException e) {
e.printStackTrace();
+ // Memoize the same sentinel the NullPointerException sibling
stores: the
+ // trailing opMap.get() would otherwise hand the caller a null
version, and
+ // every later call for this operator would re-run the failing
`git log`.
+ opMap.put(operatorName, "N/A");
} catch (NullPointerException e) {
opMap.put(operatorName, "N/A");
}
diff --git
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/metadata/OPVersionSpec.scala
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/metadata/OPVersionSpec.scala
index a61014569a..2004bf489c 100644
---
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/metadata/OPVersionSpec.scala
+++
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/metadata/OPVersionSpec.scala
@@ -53,9 +53,15 @@ import scala.util.Using
* - a repository with history: the newest commit touching the operator's
path,
* memoized under the operator name;
* - a repository with an unborn HEAD: `LogCommand.call()` raises
`NoHeadException`
- * (a `GitAPIException`) and resolution must not propagate it;
+ * (a `GitAPIException`), which resolution must not propagate and must
answer with
+ * the `"N/A"` fallback;
* - no handle at all: `git.log()` raises `NullPointerException` and
resolution
- * takes the `"N/A"` fallback.
+ * takes the same `"N/A"` fallback.
+ *
+ * Both failure paths are pinned to `"N/A"` rather than to null, and both are
pinned as
+ * memoized: the version string is embedded in operator metadata, and a
caller that is
+ * handed null has no way to tell "resolution failed" from "the field was
never set",
+ * while an unmemoized failure makes every later call retry the same failing
`git log`.
*
* On top of that this spec pins the memoization contract: the answer is
cached per
* operator name and the path is ignored on a cache hit.
@@ -63,11 +69,6 @@ import scala.util.Using
* Deliberately NOT covered: the static initializer itself. It runs once,
before any
* test can observe it, and which of its two branches executes is fixed by
how the
* tree was checked out — no test can flip it without mutating the JVM's
environment.
- *
- * Deliberately NOT asserted: the value the `GitAPIException` path returns.
That catch
- * leaves `opMap` unpopulated, so the trailing `opMap.get(operatorName)`
hands the caller
- * a null version — a latent defect (the `NullPointerException` sibling
stores `"N/A"`).
- * Pinning the null would cement it, so only the swallow itself is asserted.
*/
class OPVersionSpec extends AnyFlatSpec with Matchers {
@@ -200,6 +201,37 @@ class OPVersionSpec extends AnyFlatSpec with Matchers {
}
}
+ it should "fall back to \"N/A\" when the git log call fails" in {
+ val name = uniqueName()
+ withCleanCache(name) {
+ withTempRepo("opversion-unborn-fallback") { (handle, _) =>
+ withGit(handle) {
+ // Same unborn-HEAD repository as above, so the GitAPIException
catch runs. The
+ // answer must be the same non-null sentinel the
NullPointerException sibling
+ // produces: this value is handed straight into operator metadata,
and null
+ // there is indistinguishable from an unset field.
+ OPVersion.getVersion(name, "alpha.txt") shouldBe "N/A"
+ }
+ }
+ }
+ }
+
+ it should "memoize the \"N/A\" fallback so a failing git log is not retried"
in {
+ val name = uniqueName()
+ withCleanCache(name) {
+ withTempRepo("opversion-unborn-memo") { (handle, _) =>
+ withGit(handle) {
+ opMap.containsKey(name) shouldBe false
+ OPVersion.getVersion(name, "alpha.txt") shouldBe "N/A"
+ // Without the memo every later call for this operator re-runs the
same failing
+ // `git log`; the cache lookup is what makes the failure a one-off.
+ opMap.containsKey(name) shouldBe true
+ opMap.get(name) shouldBe "N/A"
+ }
+ }
+ }
+ }
+
// -- resolution with no usable handle
----------------------------------------
it should "fall back to \"N/A\" when the git handle is unavailable" in {