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 436b37e9b6 fix(workflow-operator): disallow multiple links into Loop
Start's input port (#7154)
436b37e9b6 is described below
commit 436b37e9b62e6be2ef8ea911ec30387dd7e80415
Author: Xinyuan Lin <[email protected]>
AuthorDate: Sat Aug 1 18:38:55 2026 -0700
fix(workflow-operator): disallow multiple links into Loop Start's input
port (#7154)
### What changes were proposed in this PR?
A Loop Start whose single input port is fed by **two** upstream
operators is accepted by the GUI, then rejected at `StartWorkflow`:
```
requirement failed: Loop Start input port ... expected exactly one reader
URI, got 2
```
Nothing in the editor hints the plan is invalid until the run fails
([discussion #6966](https://github.com/apache/texera/discussions/6966)).
The restriction itself is intended — fan-in belongs in a `Union` before
the loop — but it should be visible while building the workflow.
`InputPort` already has a `disallowMultiLinks` flag, and the frontend
honors it in two places:
| Guard | Where | Source of the flag |
|---|---|---|
| Editor refuses to draw a second link into the port |
`workflow-editor.component.ts` | operator metadata
(`additionalMetadata.inputPorts[i].disallowMultiLinks`) |
| Workflow validation requires exactly one input |
`validation-workflow.service.ts` | the operator predicate, via
`WorkflowUtilService.inputPortToPortDescription` mapping
`disallowMultiLinks` → `disallowMultiInputs` |
The loop operators simply never set it. This PR sets it on the shared
`LoopOpDesc` input port — **one line**, no frontend change.
It applies to **Loop Start only**. Loop End supports fan-in — a loop
body may branch and converge on it — so this PR also makes that work:
every reader on the input port replays its own branch's copy of the same
iteration's state, so `MainLoop` now consumes it once per iteration and
drops the duplicates (the copies are identical, being one emission from
the matching Loop Start, and a consume emits nothing downstream).
Without that, `update` would run once per branch and the loop would end
early with wrong results.
| | inbound links | why |
|---|---|---|
| Loop Start | exactly 1 | the scheduler resolves the loop's bookkeeping
URIs from that port's single reader — put a `Union` before the loop |
| Loop End | 1 or more | a branching loop body converges here; the loop
state is consumed once per iteration |
The scheduler's `require` (which is Loop-Start-only, under
`filter(_.isLoopStart)`) stays as a defense-in-depth backstop for
programmatically built plans, which bypass the GUI entirely.
**Scope of the guard (corrected).** The two frontend guards read the
flag from different places, so they cover different cases:
| Guard | Reads from | Covers already-saved loop operators? |
|---|---|---|
| Editor refuses to draw a 2nd link
(`workflow-editor.component.ts:1124`) | dynamic **schema** | **yes** |
| Validation requires exactly 1 input
(`validation-workflow.service.ts:328`) | the saved **operator
predicate** | **no** — `updateOperatorVersion` only rebuilds ports from
the schema when the saved port list is empty, so an operator saved
before this change keeps `disallowMultiInputs: false` |
So this stops new second links everywhere (the valuable half), but a
workflow that *already* has two links — including JSON assembled outside
the GUI and then opened — still validates clean and still fails at
`StartWorkflow`. Making validation cover existing content needs
`inputPortToPortDescription` re-applied on load, or a schema fallback in
validation; that is a separate change.
### Any related issues, documentation, discussions?
Closes #7155
Closes #7246
Addresses the GUI half of [discussion
#6966](https://github.com/apache/texera/discussions/6966).
### How was this PR tested?
`LoopStartOpDescSpec` and `LoopEndOpDescSpec` each gain a case pinning
`inputPorts.head.disallowMultiLinks shouldBe true` (both fail before the
change, 29/29 pass after). `scalafmtCheckAll` + `scalafixAll --check`
clean on Java 17.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 5)
---------
Signed-off-by: Xinyuan Lin <[email protected]>
---
amber/src/main/python/core/runnables/main_loop.py | 18 +++++++++++
.../test/python/core/runnables/test_main_loop.py | 37 ++++++++++++++++++++++
.../texera/amber/operator/loop/LoopOpDesc.scala | 17 +++++++++-
.../amber/operator/loop/LoopStartOpDesc.scala | 5 +++
.../amber/operator/loop/LoopEndOpDescSpec.scala | 7 ++++
.../amber/operator/loop/LoopOpDescSpecMixin.scala | 12 +++++++
.../amber/operator/loop/LoopStartOpDescSpec.scala | 4 +++
.../validation/validation-workflow.service.spec.ts | 37 ++++++++++++++++++++++
8 files changed, 136 insertions(+), 1 deletion(-)
diff --git a/amber/src/main/python/core/runnables/main_loop.py
b/amber/src/main/python/core/runnables/main_loop.py
index d252be934e..fe44b36044 100644
--- a/amber/src/main/python/core/runnables/main_loop.py
+++ b/amber/src/main/python/core/runnables/main_loop.py
@@ -87,6 +87,12 @@ class MainLoop(StoppableQueueBlockingRunnable):
# LoopEnd (loop_counter == 0) takes a state; used for the jump RPC
# and the setup-config URI lookup (context.loop_start_state_uris).
self._loop_start_id: str = ""
+ # Whether this LoopEnd already consumed its loop state this execution.
+ # A loop body may branch and converge on the Loop End, and every reader
+ # on its input port replays that branch's states independently, so the
+ # same iteration's state arrives once per branch. Workers are recreated
+ # on each region re-execution, so this instance flag is per iteration.
+ self._loop_state_consumed: bool = False
self.context = Context(worker_id, input_queue)
self._async_rpc_server = AsyncRPCServer(output_queue,
context=self.context)
@@ -377,6 +383,18 @@ class MainLoop(StoppableQueueBlockingRunnable):
# Matching LoopEnd (in_counter == 0): it will consume this state
# and jump back. Remember which LoopStart to jump to (it rides
# the envelope) for complete()/_jump_to_loop_start.
+ #
+ # With a branching loop body, each branch's reader replays the same
+ # iteration's state, so this fires once per inbound link. Consume
+ # only the first: running the user's `update` again would advance
+ # the loop variables once per branch. The copies are identical --
+ # they are the same state emitted by the one matching LoopStart --
+ # so dropping them loses nothing (a consume emits nothing
+ # downstream either way).
+ if self._loop_state_consumed:
+ self._check_and_process_control()
+ return
+ self._loop_state_consumed = True
self._loop_start_id = frame.loop_start_id
self.context.state_processing_manager.current_input_state = state
diff --git a/amber/src/test/python/core/runnables/test_main_loop.py
b/amber/src/test/python/core/runnables/test_main_loop.py
index 78ec517635..73f3957559 100644
--- a/amber/src/test/python/core/runnables/test_main_loop.py
+++ b/amber/src/test/python/core/runnables/test_main_loop.py
@@ -2495,6 +2495,43 @@ class TestMainLoop:
assert "loop_start_id" not in passed_to_operator
assert "loop_counter" not in passed_to_operator
+ def test_loopend_consumes_its_loop_state_once_per_iteration(
+ self, main_loop, monkeypatch
+ ):
+ # A loop body may branch and converge on the Loop End, so its input
+ # port takes fan-in. Every reader on that port replays its own
+ # branch's states, so the SAME iteration's state arrives once per
+ # branch. Only the first may be consumed: running the user's `update`
+ # again would advance the loop variables once per branch (e.g. `i += 1`
+ # twice), ending the loop early with wrong results.
+ main_loop.context.executor_manager.executor = _FalseLoopEnd()
+ emitted, switched, reset_calls = self._capture_state_emit(
+ main_loop, monkeypatch
+ )
+ monkeypatch.setattr(
+ main_loop.context.state_processing_manager,
+ "get_output_state",
+ lambda: None,
+ )
+
+ def deliver():
+ main_loop._process_state_frame(
+ StateFrame(
+ State({"i": 42}),
+ loop_counter=0,
+ loop_start_id="outer-loop",
+ )
+ )
+
+ deliver() # branch A
+ deliver() # branch B replays the same iteration's state
+
+ assert switched == [True], "the operator must consume exactly once"
+ assert emitted == [], "a consume emits nothing downstream, duplicate
or not"
+ assert reset_calls == []
+ assert main_loop._loop_start_id == "outer-loop"
+ assert main_loop._loop_state_consumed is True
+
# ------------------------------------------------------------------ #
# _jump_to_loop_start
#
diff --git
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/loop/LoopOpDesc.scala
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/loop/LoopOpDesc.scala
index 5783cda99e..89e73261d7 100644
---
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/loop/LoopOpDesc.scala
+++
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/loop/LoopOpDesc.scala
@@ -67,6 +67,17 @@ abstract class LoopOpDesc extends LogicalOp {
@JsonIgnore
protected def isLoopStart: Boolean = false
+ /**
+ * Whether this operator's input port takes exactly one inbound link.
+ *
+ * Only Loop Start does: the scheduler resolves its loop bookkeeping URIs
+ * from that port's single reader. Loop End accepts fan-in -- a loop body
+ * may branch and converge on it -- and the runtime consumes the loop state
+ * once per iteration no matter how many branches replay it (see
+ * MainLoop._process_state_frame).
+ */
+ protected def disallowMultiInputLinks: Boolean = false
+
override def getPhysicalOp(
workflowId: WorkflowIdentity,
executionId: ExecutionIdentity
@@ -94,7 +105,11 @@ abstract class LoopOpDesc extends LogicalOp {
operatorName,
operatorDescription,
OperatorGroupConstants.CONTROL_GROUP,
- inputPorts = List(InputPort()),
+ // Declaring the single-link restriction here is what makes the GUI
+ // refuse to draw a second link, instead of the plan being rejected only
+ // at StartWorkflow (discussion #6966). It applies to Loop Start alone --
+ // see disallowMultiInputLinks.
+ inputPorts = List(InputPort(disallowMultiLinks =
disallowMultiInputLinks)),
// Loop End reuses its output storage across region re-executions (it
// accumulates across the iterations of its own loop); the flag is
// declared on the output port and the region scheduler reads it there.
diff --git
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/loop/LoopStartOpDesc.scala
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/loop/LoopStartOpDesc.scala
index 7817d7035f..aa9c1fcf5e 100644
---
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/loop/LoopStartOpDesc.scala
+++
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/loop/LoopStartOpDesc.scala
@@ -42,6 +42,11 @@ class LoopStartOpDesc extends LoopOpDesc {
// this operator's input-port state URI and ships it to workers at setup.
override protected def isLoopStart: Boolean = true
+ // The scheduler resolves this loop's bookkeeping URIs from this port's
+ // single reader (WorkflowExecutionManager requires exactly one storage
+ // pair), so fan-in here has no meaning -- put a Union before the loop.
+ override protected def disallowMultiInputLinks: Boolean = true
+
// `initialization` and `output` are base64-wrapped by `pyb`; see
// LoopOpDesc.generatePythonCode.
override def generatePythonCode(): String = {
diff --git
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/loop/LoopEndOpDescSpec.scala
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/loop/LoopEndOpDescSpec.scala
index 5f4b88b82e..c8dc04688f 100644
---
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/loop/LoopEndOpDescSpec.scala
+++
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/loop/LoopEndOpDescSpec.scala
@@ -49,6 +49,13 @@ class LoopEndOpDescSpec extends AnyFlatSpec with
LoopOpDescSpecMixin {
info.outputPorts should have length 1
}
+ it should "allow more than one link into its input port" in {
+ // A loop body may branch and converge on the Loop End, so fan-in is
+ // supported: the runtime consumes the loop state once per iteration
+ // however many branches replay it (MainLoop._process_state_frame).
+ assertDisallowsMultiInputLinks(desc(), expected = false)
+ }
+
"LoopEndOpDesc.generatePythonCode" should "wrap user inputs in the base64
decode template" in {
// Distinct sentinels so we know the codegen wires the right user
// field into the right `decode_python_template` site. If `condition`
diff --git
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/loop/LoopOpDescSpecMixin.scala
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/loop/LoopOpDescSpecMixin.scala
index 7b9eed231c..a2044eee21 100644
---
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/loop/LoopOpDescSpecMixin.scala
+++
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/loop/LoopOpDescSpecMixin.scala
@@ -74,6 +74,18 @@ trait LoopOpDescSpecMixin extends Matchers {
physical.outputPorts.size shouldBe opDesc.operatorInfo.outputPorts.size
}
+ /** Pins whether the GUI may draw more than one link into this operator's
+ * input port. Only Loop Start restricts it -- the scheduler resolves its
+ * loop bookkeeping URIs from that port's single reader. Loop End accepts
+ * fan-in (a loop body may branch and converge on it); the runtime consumes
+ * the loop state once per iteration however many branches replay it.
+ */
+ protected def assertDisallowsMultiInputLinks(
+ opDesc: LogicalOp,
+ expected: Boolean
+ ): Assertion =
+ opDesc.operatorInfo.inputPorts.head.disallowMultiLinks shouldBe expected
+
protected def assertOpExecWithPythonCodeForClass(
physical: PhysicalOp,
expectedSubclassDecl: String
diff --git
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/loop/LoopStartOpDescSpec.scala
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/loop/LoopStartOpDescSpec.scala
index cc86c5d9d2..94451b6ed7 100644
---
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/loop/LoopStartOpDescSpec.scala
+++
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/loop/LoopStartOpDescSpec.scala
@@ -46,6 +46,10 @@ class LoopStartOpDescSpec extends AnyFlatSpec with
LoopOpDescSpecMixin {
info.outputPorts should have length 1
}
+ it should "disallow more than one link into its input port" in {
+ assertDisallowsMultiInputLinks(desc(), expected = true)
+ }
+
"LoopStartOpDesc.generatePythonCode" should "wrap user inputs in the base64
decode template" in {
// Distinct sentinels prove the codegen routes the right user field
// through the encode pipeline (not accidentally swapped) and that
diff --git
a/frontend/src/app/workspace/service/validation/validation-workflow.service.spec.ts
b/frontend/src/app/workspace/service/validation/validation-workflow.service.spec.ts
index 4039c441f2..3fe5944c14 100644
---
a/frontend/src/app/workspace/service/validation/validation-workflow.service.spec.ts
+++
b/frontend/src/app/workspace/service/validation/validation-workflow.service.spec.ts
@@ -227,4 +227,41 @@ describe("ValidationWorkflowService", () => {
workflowActionservice.getTexeraGraph().disableOperator(mockResultPredicate2.operatorID);
expect(Object.entries(validationWorkflowService.getCurrentWorkflowValidationError().errors).length).toEqual(0);
});
+
+ it("should report an operator invalid when a disallowMultiInputs port has
two enabled links", () => {
+ // The guard behind `disallowMultiLinks` on a loop operator's input port
+ // (LoopOpDesc): a port declared single-input must have exactly one inbound
+ // link, so fanning two producers into it is invalid rather than silently
+ // accepted and failing at StartWorkflow.
+ const singleInputSink = {
+ ...mockResultPredicate,
+ operatorID: "single-input-sink",
+ inputPorts: [{ portID: "input-0", disallowMultiInputs: true }],
+ };
+ const secondSource = { ...mockScanPredicate, operatorID: "scan-2" };
+ const linkFromFirst = {
+ linkID: "link-single-input-1",
+ source: { operatorID: mockScanPredicate.operatorID, portID: "output-0" },
+ target: { operatorID: singleInputSink.operatorID, portID: "input-0" },
+ };
+ const linkFromSecond = {
+ linkID: "link-single-input-2",
+ source: { operatorID: secondSource.operatorID, portID: "output-0" },
+ target: { operatorID: singleInputSink.operatorID, portID: "input-0" },
+ };
+
+ workflowActionservice.addOperator(mockScanPredicate, mockPoint);
+ workflowActionservice.addOperator(secondSource, mockPoint);
+ workflowActionservice.addOperator(singleInputSink, mockPoint);
+
+ workflowActionservice.addLink(linkFromFirst);
+
expect(validationWorkflowService.validateOperator(singleInputSink.operatorID).isValid).toBeTruthy();
+
+ workflowActionservice.addLink(linkFromSecond);
+ const validation =
validationWorkflowService.validateOperator(singleInputSink.operatorID);
+ expect(validation.isValid).toBeFalsy();
+ if (!validation.isValid) {
+ expect(validation.messages["inputs"]).toContain("requires 1 input, has
2");
+ }
+ });
});