This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch
gh-readonly-queue/release/v1.2/pr-7637-5c0edbf41ea7eafba9a58df4909ee827463d14f3
in repository https://gitbox.apache.org/repos/asf/texera.git
commit 83317c1d98aeb4ab9badbba42c0490f87fd591f0
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Sat Sep 19 19:09:43 2026 +0000
fix(agent-service, v1.2): delete links targeting removed input ports when
shrinking input ports (#7637)
### What changes were proposed in this PR?
Automated backport of #7349 to `release/v1.2`.
Source: 12169c2cfe5f92d0c492ae12d8eb2b53a6fec098 ยท [automation
run](https://github.com/apache/texera/actions/runs/31746378147)
### Any related issues, documentation, discussions?
Backport of #7349. Originally linked #7169.
### How was this PR tested?
Release-branch CI runs on this branch once the conflicts are resolved
and this PR is marked ready for review.
### Was this PR authored or co-authored using generative AI tooling?
No.
Co-authored-by: Martin Vu <[email protected]>
---
agent-service/src/agent/workflow-state.spec.ts | 401 +++++++++++++++++++++++++
agent-service/src/agent/workflow-state.ts | 9 +
2 files changed, 410 insertions(+)
diff --git a/agent-service/src/agent/workflow-state.spec.ts
b/agent-service/src/agent/workflow-state.spec.ts
new file mode 100644
index 0000000000..3c17b352de
--- /dev/null
+++ b/agent-service/src/agent/workflow-state.spec.ts
@@ -0,0 +1,401 @@
+/**
+ * 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.
+ */
+
+import { describe, expect, test } from "bun:test";
+import { WorkflowState } from "./workflow-state";
+import type { OperatorPredicate, OperatorLink, ValidationError,
WorkflowContent } from "../types/workflow";
+
+function makeOperator(id: string, overrides: Partial<OperatorPredicate> = {}):
OperatorPredicate {
+ return {
+ operatorID: id,
+ operatorType: "TestOp",
+ operatorVersion: "1.0",
+ operatorProperties: {},
+ inputPorts: [{ portID: "input-0", displayName: "Input 0" }],
+ outputPorts: [{ portID: "output-0", displayName: "Output 0" }],
+ showAdvanced: false,
+ ...overrides,
+ };
+}
+
+function makeLink(linkId: string, sourceId: string, targetId: string):
OperatorLink {
+ return {
+ linkID: linkId,
+ source: { operatorID: sourceId, portID: "output-0" },
+ target: { operatorID: targetId, portID: "input-0" },
+ };
+}
+
+describe("WorkflowState - operators", () => {
+ test("add and get operator round-trips", () => {
+ const state = new WorkflowState();
+ const op = makeOperator("op1");
+ state.addOperator(op);
+ expect(state.getOperator("op1")).toEqual(op);
+ expect(state.getAllOperators()).toHaveLength(1);
+ });
+
+ test("delete operator removes connected links", () => {
+ const state = new WorkflowState();
+ state.addOperator(makeOperator("op1"));
+ state.addOperator(makeOperator("op2"));
+ state.addLink(makeLink("l1", "op1", "op2"));
+
+ expect(state.deleteOperator("op1")).toBe(true);
+ expect(state.getOperator("op1")).toBeUndefined();
+ expect(state.getAllLinks()).toHaveLength(0);
+ });
+
+ test("delete on missing operator returns false", () => {
+ const state = new WorkflowState();
+ expect(state.deleteOperator("missing")).toBe(false);
+ });
+
+ test("updateOperatorProperties merges, does not replace", () => {
+ const state = new WorkflowState();
+ state.addOperator(makeOperator("op1", { operatorProperties: { a: 1, b: 2 }
}));
+ state.updateOperatorProperties("op1", { b: 99, c: 3 });
+
+ expect(state.getOperator("op1")?.operatorProperties).toEqual({ a: 1, b:
99, c: 3 });
+ });
+
+ test("updateOperatorDisplayName sets customDisplayName", () => {
+ const state = new WorkflowState();
+ state.addOperator(makeOperator("op1"));
+ expect(state.updateOperatorDisplayName("op1", "Filter rows")).toBe(true);
+ expect(state.getOperator("op1")?.customDisplayName).toBe("Filter rows");
+ });
+
+ test("update on missing operator returns false", () => {
+ const state = new WorkflowState();
+ expect(state.updateOperatorProperties("missing", { a: 1 })).toBe(false);
+ expect(state.updateOperatorDisplayName("missing", "x")).toBe(false);
+ });
+});
+
+describe("WorkflowState - links", () => {
+ test("add, get, and delete link", () => {
+ const state = new WorkflowState();
+ state.addOperator(makeOperator("op1"));
+ state.addOperator(makeOperator("op2"));
+ const link = makeLink("l1", "op1", "op2");
+ state.addLink(link);
+
+ expect(state.getLink("l1")).toEqual(link);
+ expect(state.deleteLink("l1")).toBe(true);
+ expect(state.getLink("l1")).toBeUndefined();
+ });
+
+ test("getLinksConnectedToOperator returns both inbound and outbound", () => {
+ const state = new WorkflowState();
+ state.addOperator(makeOperator("op1"));
+ state.addOperator(makeOperator("op2"));
+ state.addOperator(makeOperator("op3"));
+ state.addLink(makeLink("l1", "op1", "op2"));
+ state.addLink(makeLink("l2", "op2", "op3"));
+
+ const connected = state.getLinksConnectedToOperator("op2");
+ expect(connected.map(l => l.linkID).sort()).toEqual(["l1", "l2"]);
+ });
+});
+
+describe("WorkflowState - generated ids", () => {
+ test("generateLinkId is monotonically increasing", () => {
+ const state = new WorkflowState();
+ expect(state.generateLinkId()).toBe("link-1");
+ expect(state.generateLinkId()).toBe("link-2");
+ expect(state.generateLinkId()).toBe("link-3");
+ });
+
+ test("generateOperatorId is namespaced by type", () => {
+ const state = new WorkflowState();
+ expect(state.generateOperatorId("Filter")).toBe("Filter-operator-1");
+ expect(state.generateOperatorId("Filter")).toBe("Filter-operator-2");
+ expect(state.generateOperatorId("Sort")).toBe("Sort-operator-3");
+ });
+});
+
+describe("WorkflowState - getSubDAG", () => {
+ test("walks ancestors of the target operator", () => {
+ // op1 -> op2 -> op4
+ // op3 -> op4
+ // sub-DAG of op4 should include all four.
+ const state = new WorkflowState();
+ state.addOperator(makeOperator("op1"));
+ state.addOperator(makeOperator("op2"));
+ state.addOperator(makeOperator("op3"));
+ state.addOperator(makeOperator("op4"));
+ state.addLink(makeLink("l1", "op1", "op2"));
+ state.addLink(makeLink("l2", "op2", "op4"));
+ state.addLink(makeLink("l3", "op3", "op4"));
+
+ const subDag = state.getSubDAG("op4");
+ expect(subDag.operators.map(o => o.operatorID).sort()).toEqual(["op1",
"op2", "op3", "op4"]);
+ expect(subDag.links.map(l => l.linkID).sort()).toEqual(["l1", "l2", "l3"]);
+ });
+
+ test("excludes downstream operators", () => {
+ // op1 -> op2 -> op3
+ // sub-DAG of op2 should include op1 and op2 but not op3.
+ const state = new WorkflowState();
+ state.addOperator(makeOperator("op1"));
+ state.addOperator(makeOperator("op2"));
+ state.addOperator(makeOperator("op3"));
+ state.addLink(makeLink("l1", "op1", "op2"));
+ state.addLink(makeLink("l2", "op2", "op3"));
+
+ const subDag = state.getSubDAG("op2");
+ expect(subDag.operators.map(o => o.operatorID).sort()).toEqual(["op1",
"op2"]);
+ });
+
+ test("disabled upstream operators are skipped", () => {
+ const state = new WorkflowState();
+ state.addOperator(makeOperator("op1", { isDisabled: true }));
+ state.addOperator(makeOperator("op2"));
+ state.addLink(makeLink("l1", "op1", "op2"));
+
+ const subDag = state.getSubDAG("op2");
+ expect(subDag.operators.map(o => o.operatorID)).toEqual(["op2"]);
+ });
+});
+
+describe("WorkflowState - toLogicalPlan", () => {
+ test("produces operators, port-indexed links, and an empty reuse list", ()
=> {
+ const state = new WorkflowState();
+ state.addOperator(makeOperator("op1"));
+ state.addOperator(makeOperator("op2"));
+ state.addLink(makeLink("l1", "op1", "op2"));
+
+ const plan = state.toLogicalPlan();
+
+ expect(plan.operators.map(o => o.operatorID)).toEqual(["op1", "op2"]);
+ // operatorProperties are spread onto each logical operator; type + ports
carry through
+ expect(plan.operators[0].operatorType).toBe("TestOp");
+ expect(plan.links).toEqual([
+ { fromOpId: "op1", fromPortId: { id: 0, internal: false }, toOpId:
"op2", toPortId: { id: 0, internal: false } },
+ ]);
+ expect(plan.opsToReuseResult).toEqual([]);
+ });
+
+ test("resolves the link port indices from the operators' port lists", () => {
+ const state = new WorkflowState();
+ state.addOperator(
+ makeOperator("src", {
+ outputPorts: [
+ { portID: "output-0", displayName: "Output 0" },
+ { portID: "output-1", displayName: "Output 1" },
+ ],
+ })
+ );
+ state.addOperator(
+ makeOperator("dst", {
+ inputPorts: [
+ { portID: "input-0", displayName: "Input 0" },
+ { portID: "input-1", displayName: "Input 1" },
+ ],
+ })
+ );
+ state.addLink({
+ linkID: "l1",
+ source: { operatorID: "src", portID: "output-1" },
+ target: { operatorID: "dst", portID: "input-1" },
+ });
+
+ expect(state.toLogicalPlan().links[0]).toEqual({
+ fromOpId: "src",
+ fromPortId: { id: 1, internal: false },
+ toOpId: "dst",
+ toPortId: { id: 1, internal: false },
+ });
+ });
+});
+
+describe("WorkflowState - traversal", () => {
+ // chain: op1 -> op2 -> op3
+ function seedChain(): WorkflowState {
+ const state = new WorkflowState();
+ state.addOperator(makeOperator("op1"));
+ state.addOperator(makeOperator("op2"));
+ state.addOperator(makeOperator("op3"));
+ state.addLink(makeLink("l1", "op1", "op2"));
+ state.addLink(makeLink("l2", "op2", "op3"));
+ return state;
+ }
+
+ test("getFrontierOperators returns the leaf operators at depth 1", () => {
+ // op3 is the only operator that is not the source of a link
+ expect(seedChain().getFrontierOperators(1)).toEqual(["op3"]);
+ });
+
+ test("getFrontierOperators expands one hop upstream per depth, topologically
ordered", () => {
+ const state = seedChain();
+ expect(state.getFrontierOperators(2)).toEqual(["op2", "op3"]);
+ expect(state.getFrontierOperators(3)).toEqual(["op1", "op2", "op3"]);
+ });
+
+ test("getFrontierOperators returns an empty list for an empty workflow", ()
=> {
+ expect(new WorkflowState().getFrontierOperators(3)).toEqual([]);
+ });
+
+ test("getSubDAG collects the target and every upstream operator and link",
() => {
+ const sub = seedChain().getSubDAG("op3");
+ expect(sub.operators.map(o => o.operatorID).sort()).toEqual(["op1", "op2",
"op3"]);
+ expect(sub.links.map(l => l.linkID).sort()).toEqual(["l1", "l2"]);
+ });
+});
+
+describe("WorkflowState - validation state", () => {
+ const err = (message: string): ValidationError => ({ isValid: false,
messages: { general: message } });
+
+ test("setValidationError stores and getValidationOutput aggregates;
clearValidationError removes", () => {
+ const state = new WorkflowState();
+
+ state.setValidationError("op1", err("bad"));
+ expect(state.getValidationOutput().errors).toEqual({ op1: err("bad") });
+
+ state.clearValidationError("op1");
+ expect(state.getValidationOutput().errors).toEqual({});
+ });
+
+ test("setAllValidationErrors replaces the map and recomputes the empty-state
flag", () => {
+ const state = new WorkflowState();
+ state.addOperator(makeOperator("op1"));
+
+ state.setAllValidationErrors({ op1: err("x") });
+
+ const out = state.getValidationOutput();
+ expect(out.errors).toEqual({ op1: err("x") });
+ expect(out.workflowEmpty).toBe(false); // one enabled operator present
+ });
+
+ test("workflowEmpty is true with no operators and when every operator is
disabled", () => {
+ const state = new WorkflowState();
+
+ state.setAllValidationErrors({});
+ expect(state.getValidationOutput().workflowEmpty).toBe(true);
+
+ state.addOperator(makeOperator("op1", { isDisabled: true }));
+ state.setAllValidationErrors({});
+ expect(state.getValidationOutput().workflowEmpty).toBe(true);
+ });
+
+ test("getValidationChangedStream emits the aggregated output on every
mutation", () => {
+ const state = new WorkflowState();
+ const emitted: Array<ReturnType<WorkflowState["getValidationOutput"]>> =
[];
+ state.getValidationChangedStream().subscribe(v => emitted.push(v));
+
+ state.setValidationError("op1", err("x"));
+ state.clearValidationError("op1");
+
+ expect(emitted).toHaveLength(2);
+ expect(emitted[0].errors).toEqual({ op1: err("x") });
+ expect(emitted[1].errors).toEqual({});
+ });
+});
+
+describe("WorkflowState - updateOperatorInputPorts", () => {
+ test("rebuilds the input ports to the requested count, flagging the extras
dynamic", () => {
+ const state = new WorkflowState();
+ state.addOperator(makeOperator("op1"));
+
+ expect(state.updateOperatorInputPorts("op1", 3)).toBe(true);
+
+ const ports = state.getOperator("op1")!.inputPorts;
+ expect(ports.map(p => p.portID)).toEqual(["input-0", "input-1",
"input-2"]);
+ expect(ports[0].isDynamicPort).toBe(false); // port 0 is static
+ expect(ports[1].isDynamicPort).toBe(true); // extra ports are dynamic
+ });
+
+ test("reducing the count drops the extra ports", () => {
+ const state = new WorkflowState();
+ state.addOperator(
+ makeOperator("op1", {
+ inputPorts: [
+ { portID: "input-0", displayName: "Input 0" },
+ { portID: "input-1", displayName: "Input 1" },
+ ],
+ })
+ );
+
+ state.updateOperatorInputPorts("op1", 1);
+
+ expect(state.getOperator("op1")!.inputPorts.map(p =>
p.portID)).toEqual(["input-0"]);
+ });
+
+ test("returns false for a missing operator", () => {
+ expect(new WorkflowState().updateOperatorInputPorts("missing",
2)).toBe(false);
+ });
+
+ test("removes links targeting input ports that are dropped", () => {
+ const state = new WorkflowState();
+
+ state.addOperator(makeOperator("src"));
+ state.addOperator(
+ makeOperator("op1", {
+ inputPorts: [
+ { portID: "input-0", displayName: "Input 0" },
+ { portID: "input-1", displayName: "Input 1" },
+ ],
+ })
+ );
+
+ state.addLink({
+ linkID: "l1",
+ source: { operatorID: "src", portID: "output-0" },
+ target: { operatorID: "op1", portID: "input-1" },
+ });
+
+ state.updateOperatorInputPorts("op1", 1);
+
+ expect(state.getAllLinks().map(l => l.linkID)).toEqual([]);
+ });
+});
+
+describe("WorkflowState - workflow content round-trip", () => {
+ test("setWorkflowContent replaces the state and getWorkflowContent reflects
it", () => {
+ const state = new WorkflowState();
+ state.addOperator(makeOperator("stale")); // must be cleared by
setWorkflowContent
+
+ const content: WorkflowContent = {
+ operators: [makeOperator("op1"), makeOperator("op2")],
+ operatorPositions: { op1: { x: 1, y: 2 } },
+ links: [makeLink("l1", "op1", "op2")],
+ commentBoxes: [],
+ settings: { dataTransferBatchSize: 123 },
+ };
+ state.setWorkflowContent(content);
+
+ const out = state.getWorkflowContent();
+ expect(out.operators.map(o => o.operatorID)).toEqual(["op1", "op2"]);
+ expect(out.links.map(l => l.linkID)).toEqual(["l1"]);
+ expect(out.operatorPositions).toEqual({ op1: { x: 1, y: 2 } });
+ expect(out.settings).toEqual({ dataTransferBatchSize: 123 });
+ expect(state.getOperator("stale")).toBeUndefined();
+ });
+
+ test("setWorkflowContent falls back to defaults when settings/commentBoxes
are absent", () => {
+ const state = new WorkflowState();
+ // The runtime guards against missing optional fields even though the type
requires them.
+ state.setWorkflowContent({ operators: [], operatorPositions: {}, links: []
} as unknown as WorkflowContent);
+
+ const out = state.getWorkflowContent();
+ expect(out.settings).toEqual({ dataTransferBatchSize: 400 }); //
DEFAULT_WORKFLOW_SETTINGS
+ expect(out.commentBoxes).toEqual([]);
+ });
+});
diff --git a/agent-service/src/agent/workflow-state.ts
b/agent-service/src/agent/workflow-state.ts
index 04ad2b0e4e..4e78ee88e5 100644
--- a/agent-service/src/agent/workflow-state.ts
+++ b/agent-service/src/agent/workflow-state.ts
@@ -189,6 +189,15 @@ export class WorkflowState {
inputPorts: newInputPorts,
};
this.operators.set(operatorId, updatedOperator);
+
+ const validInputPorts = new Set(newInputPorts.map(port => port.portID));
+
+ for (const link of this.getAllLinks()) {
+ if (link.target.operatorID === operatorId &&
!validInputPorts.has(link.target.portID)) {
+ this.deleteLink(link.linkID);
+ }
+ }
+
this.operatorPropertyChangeSubject.next({ operator: updatedOperator });
return true;
}