From a43afd1d0e154a1592decf5614d40f4399345775 Mon Sep 17 00:00:00 2001
From: Kary Zheng <150742834+kz930@users.noreply.github.com>
Date: Sat, 8 Aug 2026 00:36:20 +0000
Subject: [PATCH] fix(visualization): take the union of Network Graph's two
node columns (#7327)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
### What changes were proposed in this PR?
Network Graph built its node set with `set(sources + destinations)`. On
two pandas Series `+` is element-wise, so the set held each source glued
to its destination rather than the union of the two columns; those glued
values were added to the graph as nodes, and the genuine nodes only
arrived afterwards with the edges. This takes the union instead, in
first-appearance order — a `set` iterates strings in an order that
varies between processes, which would leave the node sequence unstable
from run to run.
One line of code, plus a comment recording why neither `+` nor `set` is
right here.
### Any related issues, documentation, discussions?
Closes #7325.
### How was this PR tested?
`NetworkGraphOpDescSpec` gains a case asserting the node set is built as
a union: reverting the one-line change leaves it the only failing test.
It also pins the ordered de-duplication, since a `set` would satisfy
"union" while reordering the nodes between processes.
```
sbt "WorkflowOperator/testOnly org.apache.texera.amber.operator.visualization.networkGraph.NetworkGraphOpDescSpec"
```
Six cases, all passing.
Beyond that, the operator's generated module was dumped and executed
over ten rows carrying the edges `n3-to-n4`, `n1-to-n2` and `n2-to-n3`.
Before the change it produced seven nodes — the four real ones plus
`n3n4`, `n2n3` and `n1n2`, each reporting zero connections, with `n2n3`
sitting in the same picture as the genuine edge from `n2` to `n3`. After
it, four nodes with the correct connection counts and no isolated dots.
Before the change:
Selecting an integer column as the source and a string column as the
destination aborted the run with `TypeError: unsupported operand type(s)
for +: 'int' and 'str'` before the change and renders normally after it.
Before the change:
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 5)
---------
(backported from commit 357296db573a0b51b3d662d17a4113fce9ed50f7)
Co-authored-by: Claude Opus 5 (1M context)
---
.../networkGraph/NetworkGraphOpDesc.scala | 4 +-
.../networkGraph/NetworkGraphOpDescSpec.scala | 103 ++++++++++++++++++
2 files changed, 106 insertions(+), 1 deletion(-)
create mode 100644 common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/networkGraph/NetworkGraphOpDescSpec.scala
diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/networkGraph/NetworkGraphOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/networkGraph/NetworkGraphOpDesc.scala
index 4a5ea6725e6..06939671656 100644
--- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/networkGraph/NetworkGraphOpDesc.scala
+++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/networkGraph/NetworkGraphOpDesc.scala
@@ -94,7 +94,9 @@ class NetworkGraphOpDesc extends PythonOperatorDescriptor {
| if not table.empty:
| sources = table[$source]
| destinations = table[$destination]
- | nodes = set(sources + destinations)
+ | # Union of the two columns, in first-appearance order. Adding the
+ | # Series pairs them off element-wise; a set reorders per run.
+ | nodes = list(dict.fromkeys(pd.concat([sources, destinations]).tolist()))
| G = nx.Graph()
| for node in nodes:
| G.add_node(node)
diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/networkGraph/NetworkGraphOpDescSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/networkGraph/NetworkGraphOpDescSpec.scala
new file mode 100644
index 00000000000..fc666c61abd
--- /dev/null
+++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/networkGraph/NetworkGraphOpDescSpec.scala
@@ -0,0 +1,103 @@
+/*
+ * 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.operator.visualization.networkGraph
+
+import org.scalatest.BeforeAndAfter
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+import java.nio.charset.StandardCharsets
+import java.util.Base64
+
+class NetworkGraphOpDescSpec extends AnyFlatSpec with BeforeAndAfter with Matchers {
+
+ var opDesc: NetworkGraphOpDesc = _
+
+ before {
+ opDesc = new NetworkGraphOpDesc()
+ }
+
+ private def b64(s: String): String =
+ Base64.getEncoder.encodeToString(s.getBytes(StandardCharsets.UTF_8))
+
+ private def carries(output: String, name: String): Boolean =
+ output.contains(name) || output.contains(b64(name))
+
+ private def fieldPart(msg: String): String =
+ msg.toLowerCase.replace("cannot be empty", "")
+
+ it should "throw AssertionError naming the Source Column when both fields are empty" in {
+ val ex = intercept[AssertionError](opDesc.manipulateTable())
+ ex.getMessage should not be null
+ ex.getMessage should include("cannot be empty")
+ fieldPart(ex.getMessage) should include("source")
+ }
+
+ it should "throw AssertionError naming the Destination Column when only source is set" in {
+ opDesc.source = "from_node"
+ val ex = intercept[AssertionError](opDesc.manipulateTable())
+ ex.getMessage should not be null
+ ex.getMessage should include("cannot be empty")
+ fieldPart(ex.getMessage) should include("destination")
+ }
+
+ it should "throw AssertionError naming the Source Column when only destination is set" in {
+ opDesc.destination = "to_node"
+ val ex = intercept[AssertionError](opDesc.manipulateTable())
+ ex.getMessage should not be null
+ ex.getMessage should include("cannot be empty")
+ fieldPart(ex.getMessage) should include("source")
+ }
+
+ it should "render both configured columns when source and destination are set" in {
+ opDesc.source = "from_node"
+ opDesc.destination = "to_node"
+ val plain = opDesc.manipulateTable().plain
+ assert(carries(plain, "from_node"))
+ assert(carries(plain, "to_node"))
+ plain should include("dropna")
+ }
+
+ it should "generate python code carrying source, destination, and title" in {
+ opDesc.source = "from_node"
+ opDesc.destination = "to_node"
+ opDesc.title = "My Graph"
+ val code = opDesc.generatePythonCode()
+ assert(carries(code, "from_node"))
+ assert(carries(code, "to_node"))
+ assert(carries(code, "My Graph"))
+ code should include("class ProcessTableOperator(UDFTableOperator)")
+ }
+
+ it should "build the node set as a union rather than by adding the two columns" in {
+ opDesc.source = "from_node"
+ opDesc.destination = "to_node"
+ val code = opDesc.generatePythonCode()
+
+ // `sources + destinations` is element-wise on two Series, so it glued each
+ // source to its destination and those strings entered the graph as nodes.
+ code should not include "set(sources + destinations)"
+ code should include("pd.concat([sources, destinations])")
+
+ // Ordered de-duplication, not a set: a set iterates strings in an order that
+ // varies between processes, which would move the nodes from run to run.
+ code should include("dict.fromkeys")
+ }
+}