This is an automated email from the ASF dual-hosted git repository.
jason810496 pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-4-test by this push:
new fea1a47ddff [v3-4-test] Java SDK: Reject IDs that collide with task
group join nodes (#74781) (#75199)
fea1a47ddff is described below
commit fea1a47ddff8c4396ec25cca2701502d077ba287
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Tue Oct 20 18:23:56 2026 +0800
[v3-4-test] Java SDK: Reject IDs that collide with task group join nodes
(#74781) (#75199)
(cherry picked from commit 1c4af0388744cd321ce618139aad0092112ab3db)
Signed-off-by: PoAn Yang <[email protected]>
Co-authored-by: PoAn Yang <[email protected]>
Co-authored-by: Rahul Vats <[email protected]>
---
.../org/apache/airflow/sdk/AirflowProcessor.kt | 15 ++-
.../org/apache/airflow/sdk/AirflowProcessorTest.kt | 123 +++++++++++++++++++++
.../main/kotlin/org/apache/airflow/sdk/DagDef.kt | 38 ++++++-
.../kotlin/org/apache/airflow/sdk/TaskGroupRef.kt | 13 ++-
.../kotlin/org/apache/airflow/sdk/internal/Ids.kt | 13 +++
.../kotlin/org/apache/airflow/sdk/TaskGroupTest.kt | 98 ++++++++++++++++
6 files changed, 294 insertions(+), 6 deletions(-)
diff --git
a/java-sdk/processor/src/main/kotlin/org/apache/airflow/sdk/AirflowProcessor.kt
b/java-sdk/processor/src/main/kotlin/org/apache/airflow/sdk/AirflowProcessor.kt
index 666ca2e23e2..36d2f1cfdd6 100644
---
a/java-sdk/processor/src/main/kotlin/org/apache/airflow/sdk/AirflowProcessor.kt
+++
b/java-sdk/processor/src/main/kotlin/org/apache/airflow/sdk/AirflowProcessor.kt
@@ -38,6 +38,7 @@ import org.apache.airflow.sdk.internal.Refs
import org.apache.airflow.sdk.internal.SchemaFields
import org.apache.airflow.sdk.internal.TaskArgs
import org.apache.airflow.sdk.internal.TypeRef
+import org.apache.airflow.sdk.internal.deriveJoinNodeIds
import org.apache.airflow.sdk.internal.foldArgName
import org.apache.airflow.sdk.internal.registrarName
import java.time.Duration
@@ -584,6 +585,8 @@ class AirflowProcessor : AbstractProcessor() {
/**
* Rejects two tasks of the Dag sharing an ID, a task and a task group
* sharing one, and two task methods whose generated classes would collide.
+ * Also rejects a task or task group that takes the ID of a group's join
+ * node.
*/
private fun checkIds(scope: Scope) {
val declarations = scope.allTasks()
@@ -591,10 +594,20 @@ class AirflowProcessor : AbstractProcessor() {
declarations.forEach { decl ->
require(taskIds.add(decl.id)) { "Tasks in Dag have duplicate ID:
${decl.id}" }
}
- scope.allGroups().forEach { group ->
+ val groups = scope.allGroups()
+ val groupIds = groups.mapTo(mutableSetOf()) { it.fullId }
+ groups.forEach { group ->
require(group.fullId !in taskIds) {
"Dag has both a task and a task group with ID '${group.fullId}';
rename one"
}
+ deriveJoinNodeIds(group.fullId).forEach { joinId ->
+ require(joinId !in taskIds) {
+ "Task '$joinId' has the ID of a join node of task group
'${group.fullId}'; rename the task"
+ }
+ require(joinId !in groupIds) {
+ "Task group '$joinId' has the ID of a join node of task group
'${group.fullId}'; rename the nested group"
+ }
+ }
}
val byClassName = mutableMapOf<String, TaskDeclaration>()
// A task that triggers a Dag run has no generated class to collide with.
diff --git
a/java-sdk/processor/src/test/kotlin/org/apache/airflow/sdk/AirflowProcessorTest.kt
b/java-sdk/processor/src/test/kotlin/org/apache/airflow/sdk/AirflowProcessorTest.kt
index c42d52413bd..11be0f957f1 100644
---
a/java-sdk/processor/src/test/kotlin/org/apache/airflow/sdk/AirflowProcessorTest.kt
+++
b/java-sdk/processor/src/test/kotlin/org/apache/airflow/sdk/AirflowProcessorTest.kt
@@ -1777,6 +1777,129 @@ class AirflowProcessorTest {
)
}
+ @Test
+ @DisplayName("reject a task in a task group that takes the id of one of the
group's join nodes")
+ fun rejectTaskTakingJoinNodeIdOfItsGroup() {
+ val compilation =
+ compile(
+ """
+ package org.apache.airflow.example;
+ import org.apache.airflow.sdk.Airflow;
+ @Airflow.Dag
+ public class TestExample {
+ @Airflow.TaskGroup
+ static class Transform {
+ @Airflow.Task(id = "upstream_join_id") public void clean() {}
+ }
+
+ @Airflow.Deps
+ static class Wiring implements TestExampleDeps {
+ void depends() {}
+ }
+ }
+ """,
+ )
+ assertThat(compilation).failed()
+ assertThat(compilation).hadErrorContaining(
+ "Task 'Transform.upstream_join_id' has the ID of a join node of task
group 'Transform'; rename the task",
+ )
+ }
+
+ @Test
+ @DisplayName("reject a top-level task that takes the id of a task group's
join node")
+ fun rejectTopLevelTaskTakingJoinNodeId() {
+ val compilation =
+ compile(
+ """
+ package org.apache.airflow.example;
+ import org.apache.airflow.sdk.Airflow;
+ @Airflow.Dag
+ public class TestExample {
+ @Airflow.Task(id = "Transform.downstream_join_id") public void
publish() {}
+
+ @Airflow.TaskGroup
+ static class Transform {
+ @Airflow.Task public void clean() {}
+ }
+
+ @Airflow.Deps
+ static class Wiring implements TestExampleDeps {
+ void depends() {}
+ }
+ }
+ """,
+ )
+ assertThat(compilation).failed()
+ assertThat(compilation).hadErrorContaining(
+ "Task 'Transform.downstream_join_id' has the ID of a join node of task
group 'Transform'; rename the task",
+ )
+ }
+
+ @Test
+ @DisplayName("reject a task in a nested task group that takes the id of one
of that group's join nodes")
+ fun rejectTaskTakingJoinNodeIdOfNestedGroup() {
+ val compilation =
+ compile(
+ """
+ package org.apache.airflow.example;
+ import org.apache.airflow.sdk.Airflow;
+ @Airflow.Dag
+ public class TestExample {
+ @Airflow.TaskGroup
+ static class Transform {
+ @Airflow.TaskGroup
+ static class Checks {
+ @Airflow.Task(id = "downstream_join_id") public void nulls() {}
+ }
+ }
+
+ @Airflow.Deps
+ static class Wiring implements TestExampleDeps {
+ void depends() {}
+ }
+ }
+ """,
+ )
+ assertThat(compilation).failed()
+ assertThat(compilation).hadErrorContaining(
+ "Task 'Transform.Checks.downstream_join_id' has the ID of a join node of
task group 'Transform.Checks'; " +
+ "rename the task",
+ )
+ }
+
+ @Test
+ @DisplayName("reject a nested task group that takes the id of a join node of
the group holding it")
+ fun rejectNestedTaskGroupTakingJoinNodeId() {
+ listOf("upstream_join_id", "downstream_join_id").forEach { id ->
+ val compilation =
+ compile(
+ """
+ package org.apache.airflow.example;
+ import org.apache.airflow.sdk.Airflow;
+ @Airflow.Dag
+ public class TestExample {
+ @Airflow.TaskGroup
+ static class Transform {
+ @Airflow.TaskGroup(id = "$id")
+ static class Inner {
+ @Airflow.Task public void clean() {}
+ }
+ }
+
+ @Airflow.Deps
+ static class Wiring implements TestExampleDeps {
+ void depends() {}
+ }
+ }
+ """,
+ )
+ assertThat(compilation).failed()
+ assertThat(compilation).hadErrorContaining(
+ "Task group 'Transform.$id' has the ID of a join node of task group
'Transform'; rename the nested group",
+ )
+ }
+ }
+
@Test
@DisplayName("reject two task methods whose generated classes would collide")
fun rejectCollidingGeneratedTaskClasses() {
diff --git a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/DagDef.kt
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/DagDef.kt
index a4150e8a9b6..913817d0909 100644
--- a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/DagDef.kt
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/DagDef.kt
@@ -23,6 +23,7 @@ import org.apache.airflow.sdk.internal.DagSource
import org.apache.airflow.sdk.internal.GROUP_ID
import org.apache.airflow.sdk.internal.SchemaFields
import org.apache.airflow.sdk.internal.checkConfigValue
+import org.apache.airflow.sdk.internal.deriveJoinNodeIds
import org.apache.airflow.sdk.internal.deriveTaskId
import org.apache.airflow.sdk.internal.validateTaskInput
import java.util.concurrent.atomic.AtomicLong
@@ -44,9 +45,10 @@ import kotlin.Throws
* ```
*
* Dag and task IDs are validated authoritatively by the Airflow server. This
- * class accepts any ID; IDs the server would reject are reported as
- * best-effort build-time warnings by the Gradle plugin's `checkAirflowBundle`
- * task, which runs [BundleInspector] against the assembled [Bundle].
+ * class does not check the characters or length of a Dag or task ID. IDs the
+ * server would reject are reported as best-effort build-time warnings by the
+ * Gradle plugin's `checkAirflowBundle` task, which runs [BundleInspector]
+ * against the assembled [Bundle].
*
* @param id Dag identifier; must be unique within a [Bundle].
*
@@ -161,6 +163,7 @@ class DagDef(
throw IllegalArgumentException("Task '${task.id}' already belongs to Dag
'${owner.id}'")
}
require(task.id !in groups) { "Dag '$id' already has a task group with ID:
${task.id}" }
+ requireNotJoinNode(task.id)
require(tasks.putIfAbsent(task.id, task) == null) {
"Tasks in Dag have duplicate ID: ${task.id}"
}
@@ -288,7 +291,9 @@ class DagDef(
* or dashes, and differ from every task and group ID in this Dag.
* @return The group, to declare tasks in and to wire edges with.
* @throws IllegalArgumentException if [id] is not a valid group ID, or the
- * Dag already has a task or task group with that ID.
+ * Dag already has a task or task group with that ID. Also thrown if a
+ * task already has the ID of one of the group's join nodes.
+ * [TaskGroupRef] explains join nodes.
*/
fun taskGroup(id: String): TaskGroupRef = addGroup(null, id)
@@ -303,12 +308,37 @@ class DagDef(
require(groupId !in tasks && groupId !in groups) {
"Dag '$id' already has a task or task group with ID: $groupId"
}
+ requireNotJoinNode(groupId)
+ // Python accepts a task that took a join node ID of the new group before
+ // the group existed. The Graph view then draws the edges into or out of
+ // the group through that task. This check rejects that order. The Go SDK
+ // rejects it too. Only a task can already have a join node ID of the new
+ // group. A group with that ID would have to be nested inside the new
+ // group, which does not exist yet.
+ deriveJoinNodeIds(groupId).firstOrNull { it in tasks }?.let { taken ->
+ throw IllegalArgumentException(
+ "Dag '$id' already has a task with ID: $taken, which is the ID of a
join node of task group '$groupId'",
+ )
+ }
return TaskGroupRef(this, groupId, parent).also {
groups[groupId] = it
parent?.children?.add(it)
}
}
+ /**
+ * Rejects [nodeId] if a task group of this Dag has a join node with the ID
+ * [nodeId]. A join node's ID is its group's ID, a dot, and a suffix with no
+ * dot in it. So at most one group can have a join node with the ID [nodeId].
+ * That group's ID is the part of [nodeId] before its last dot.
+ */
+ private fun requireNotJoinNode(nodeId: String) {
+ val groupId = nodeId.substringBeforeLast('.', "")
+ require(groupId !in groups || nodeId !in deriveJoinNodeIds(groupId)) {
+ "Dag '$id' already has a join node of task group '$groupId' with ID:
$nodeId"
+ }
+ }
+
/**
* What this Dag's task-group edges mean in terms of tasks.
*
diff --git
a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/TaskGroupRef.kt
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/TaskGroupRef.kt
index 363f13072cd..49051aecf28 100644
--- a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/TaskGroupRef.kt
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/TaskGroupRef.kt
@@ -42,6 +42,15 @@ import org.apache.airflow.sdk.internal.deriveTaskId
* each edge as Python reads it, so a group's edges can be drawn before its
* tasks are declared. The edges still resolve in the order they were drawn.
*
+ * The Airflow UI draws the edges into a group through one join node and the
+ * edges out of it through a second join node. For `staging`, the join nodes
+ * have the IDs `staging.upstream_join_id` and `staging.downstream_join_id`,
+ * and no task or other group in the Dag can take either ID. For example,
+ * `staging.task("upstream_join_id", Stage.class)` fails because the new
+ * task's ID would be `staging.upstream_join_id`. `dag.taskGroup("staging")`
+ * also fails if the Dag already has a task with the ID
+ * `staging.upstream_join_id`.
+ *
* @property id Group ID, including any enclosing group's prefix.
*/
class TaskGroupRef internal constructor(
@@ -163,7 +172,9 @@ class TaskGroupRef internal constructor(
* underscores, or dashes.
* @return The nested group.
* @throws IllegalArgumentException if [id] is not a valid group ID, or the
- * Dag already has a task or task group with the resulting ID.
+ * Dag already has a task or task group with the resulting ID. Also
+ * thrown if the resulting ID is the ID of a join node of this group, or
+ * if a task already has the ID of a join node of the nested group.
*/
fun taskGroup(id: String): TaskGroupRef = dag.addGroup(this, id)
diff --git
a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/internal/Ids.kt
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/internal/Ids.kt
index b2824be3c91..2f38b7c13dc 100644
--- a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/internal/Ids.kt
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/internal/Ids.kt
@@ -37,3 +37,16 @@ val GROUP_ID: Regex = Regex("[A-Za-z0-9_-]+")
* check an ID it derives the same way; not user-facing API.
*/
fun deriveTaskId(definition: Class<*>): String =
definition.simpleName.replaceFirstChar { it.lowercaseChar() }
+
+/**
+ * @suppress
+ *
+ * The IDs of the two join nodes that the Airflow UI draws for the task group
+ * with full ID [groupId]. Python's `TaskGroup.upstream_join_id` and
+ * `TaskGroup.downstream_join_id` return the same IDs. The UI draws the edges
+ * into the group through the first node and the edges out of it through the
+ * second. Python reserves both IDs once the group exists. The annotation
+ * processor checks these IDs too, so this function is public. It is not part
+ * of the user-facing API.
+ */
+fun deriveJoinNodeIds(groupId: String): List<String> =
listOf("$groupId.upstream_join_id", "$groupId.downstream_join_id")
diff --git
a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/TaskGroupTest.kt
b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/TaskGroupTest.kt
index 22c5972f32e..2a69f9155fe 100644
--- a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/TaskGroupTest.kt
+++ b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/TaskGroupTest.kt
@@ -79,6 +79,104 @@ internal class TaskGroupTest {
assertEquals("Dag 'd' already has a task group with ID: staging",
error.message)
}
+ @Test
+ @DisplayName("Should reject a task in a group that takes the ID of one of
the group's join nodes")
+ fun shouldRejectTaskIdTakenByJoinNode() {
+ listOf("upstream_join_id", "downstream_join_id").forEach { localId ->
+ val transform = DagDef("d").taskGroup("transform")
+ val checks = transform.taskGroup("checks")
+
+ listOf(transform, checks).forEach { group ->
+ val error =
+ assertThrows(IllegalArgumentException::class.java) {
group.task<Unit>(localId, NoopTask::class.java) }
+
+ assertEquals(
+ "Dag 'd' already has a join node of task group '${group.id}' with
ID: ${group.id}.$localId",
+ error.message,
+ )
+ }
+ }
+ }
+
+ @Test
+ @DisplayName("Should reject a top-level task that takes the ID of a join
node of an earlier group")
+ fun shouldRejectRootTaskIdTakenByJoinNode() {
+ listOf("upstream_join_id", "downstream_join_id").forEach { suffix ->
+ val dag = DagDef("d")
+ dag.taskGroup("transform")
+
+ val error =
+ assertThrows(IllegalArgumentException::class.java) {
+ dag.task<Unit>("transform.$suffix", NoopTask::class.java)
+ }
+
+ assertEquals(
+ "Dag 'd' already has a join node of task group 'transform' with ID:
transform.$suffix",
+ error.message,
+ )
+ }
+ }
+
+ @Test
+ @DisplayName("Should reject a group when a task already has the ID of one of
its join nodes")
+ fun shouldRejectGroupWhoseJoinNodeIdIsTaken() {
+ listOf("upstream_join_id", "downstream_join_id").forEach { suffix ->
+ val dag = DagDef("d")
+ dag.task<Unit>("transform.$suffix", NoopTask::class.java)
+ val staging = dag.taskGroup("staging")
+ staging.task<Unit>("checks.$suffix", NoopTask::class.java)
+
+ val error = assertThrows(IllegalArgumentException::class.java) {
dag.taskGroup("transform") }
+ val nestedError = assertThrows(IllegalArgumentException::class.java) {
staging.taskGroup("checks") }
+
+ assertEquals(
+ "Dag 'd' already has a task with ID: transform.$suffix, " +
+ "which is the ID of a join node of task group 'transform'",
+ error.message,
+ )
+ assertEquals(
+ "Dag 'd' already has a task with ID: staging.checks.$suffix, " +
+ "which is the ID of a join node of task group 'staging.checks'",
+ nestedError.message,
+ )
+ assertEquals(listOf("staging"), dag.groups.keys.toList())
+ }
+ }
+
+ @Test
+ @DisplayName("Should reject a nested group that takes the ID of a join node
of the group holding it")
+ fun shouldRejectNestedGroupIdTakenByJoinNode() {
+ listOf("upstream_join_id", "downstream_join_id").forEach { localId ->
+ val transform = DagDef("d").taskGroup("transform")
+
+ val error = assertThrows(IllegalArgumentException::class.java) {
transform.taskGroup(localId) }
+
+ assertEquals(
+ "Dag 'd' already has a join node of task group 'transform' with ID:
transform.$localId",
+ error.message,
+ )
+ }
+ }
+
+ @Test
+ @DisplayName("Should tell a group's join node IDs apart from top-level IDs
named upstream_join_id or downstream_join_id")
+ fun shouldTellJoinNodeIdsFromIdsNamedLikeThem() {
+ val dag = DagDef("d")
+ dag.task<Unit>("downstream_join_id", NoopTask::class.java)
+ val group = dag.taskGroup("upstream_join_id")
+
+ val error =
+ assertThrows(IllegalArgumentException::class.java) {
+ group.task<Unit>("downstream_join_id", NoopTask::class.java)
+ }
+
+ assertEquals(
+ "Dag 'd' already has a join node of task group 'upstream_join_id' with
ID: upstream_join_id.downstream_join_id",
+ error.message,
+ )
+ assertEquals(listOf("downstream_join_id"), dag.tasks.keys.toList())
+ }
+
@Test
@DisplayName("Should wire a group upstream from its leaves and downstream to
its roots on registration")
fun shouldExpandGroupEdgesOntoRootsAndLeaves() {