kaxil commented on code in PR #74440:
URL: https://github.com/apache/airflow/pull/74440#discussion_r4216959274


##########
java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/DagDef.kt:
##########
@@ -404,6 +429,20 @@ class TaskDef(
   val id: String,
   val definition: Class<out Task>,
 ) : Endpoint {
+  /**
+   * Declares a task that starts a run of another Dag instead of running Java
+   * code.
+   *
+   * [DagDef.task] is the spelling user code uses; this is what a generated
+   * wiring view calls.
+   *
+   * @param id Task identifier, unique within a [DagDef].
+   * @param trigger What to trigger, and how.
+   */
+  constructor(id: String, trigger: TriggerDagRun) : this(id, 
TriggerDagRunPlaceholder::class.java) {

Review Comment:
   Every trigger task gets `TriggerDagRunPlaceholder` as its class, and 
`BranchDef.add` rejects two cases sharing a class when the decider is a 
`BranchTask`. So `dag.Branch(Pick.class).option(dag.task("a", new 
TriggerDagRun("x"))).option(dag.task("b", new TriggerDagRun("y")))` fails with 
"both run 'org.apache.airflow.sdk.TriggerDagRunPlaceholder'", a class the 
author never wrote. With a single trigger case, `byClass` can only pick it if 
`choose()` returns that internal class. Picking which downstream Dag to trigger 
seems like a natural use of a branch. Could `BranchDef.add` reject a trigger 
case under a `BranchTask` with a message pointing at `TaskIdBranchTask` (which 
works, since it matches by ID), or could trigger tasks stop sharing one class? 
A test with a branch over trigger tasks would pin whichever you pick.



##########
java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Task.kt:
##########
@@ -102,6 +102,19 @@ internal object TaskRunner {
     val taskDef =
       bundle.taskDef(request.ti.dagId, request.ti.taskId)
         ?: return TaskResult.of(TaskState.State.REMOVED)
+    taskDef.trigger?.let { trigger ->

Review Comment:
   I couldn't find a test that runs a trigger task through `runTask`. 
`TriggerRunnerTest` goes straight to `TriggerRunner.run`, and `TaskTest` only 
adds throwing stubs. So if this dispatch got deleted, every trigger task would 
instantiate the placeholder and throw, and the suite would still pass. Same for 
the catch below. Could you add a `runTask` test that checks the 
`trigger_run_id` XCom is pushed? Another where the client throws and the task 
ends up FAILED would cover the catch.



##########
java-sdk/example/src/java/org/apache/airflow/example/nativedag/InterfaceExample.java:
##########
@@ -114,12 +114,18 @@ public static DagDef build() {
 
     var reportLong = dag.task("report_long", ReportLong.class);
     var reportShort = dag.task("report_short", ReportShort.class);
+    // A task that starts a run of another Dag; it runs no Java code.
+    var trigger =
+        dag.task(
+            "trigger_downstream",
+            new 
TriggerDagRun("java_native_annotation_example").config("wait_for_completion", 
false));

Review Comment:
   This Dag triggers `java_native_annotation_example`, and 
`AnnotationExample.triggerDownstream` triggers this one back. Both are 
`@daily`, `load` always wins the condition with the fixed extract value, and 
neither sets `trigger_run_id`, so every trigger creates a fresh `manual__<now>` 
run and never hits the already-exists path. Once both are unpaused, one 
scheduled run starts a chain of runs that never ends. Could one of them trigger 
a Dag that doesn't trigger back, the way Python's 
`example_trigger_controller_dag` only points one way?



##########
java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/TriggerDagRun.kt:
##########
@@ -0,0 +1,202 @@
+/*
+ * 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.airflow.sdk
+
+import java.time.Duration
+import java.time.Instant
+import java.time.OffsetDateTime
+
+/** A Dag run state, as `allowed_states` and `failed_states` name one. */
+internal val DAG_RUN_STATES = listOf("queued", "running", "success", "failed")
+
+/** Shape of one `TriggerDagRun` setting, for [TriggerDagRun.config] to check 
a value against. */
+private enum class Setting {
+  STRING,
+  BOOLEAN,
+  DURATION,
+  DATETIME,
+  STATES,
+  CONF,
+}
+
+/**
+ * Settings named as `TriggerDagRunOperator` names its parameters. The Dag ID
+ * is the constructor argument, so `trigger_dag_id` is not among them.
+ */
+private val SETTINGS =
+  linkedMapOf(
+    "trigger_run_id" to Setting.STRING,
+    "conf" to Setting.CONF,
+    "logical_date" to Setting.DATETIME,
+    "run_after" to Setting.DATETIME,
+    "reset_dag_run" to Setting.BOOLEAN,
+    "wait_for_completion" to Setting.BOOLEAN,
+    "poke_interval" to Setting.DURATION,
+    "allowed_states" to Setting.STATES,
+    "failed_states" to Setting.STATES,
+    "skip_when_already_exists" to Setting.BOOLEAN,
+    "fail_when_dag_is_paused" to Setting.BOOLEAN,
+    "note" to Setting.STRING,
+    "deferrable" to Setting.BOOLEAN,
+  )
+
+/**
+ * A task that starts a run of another Dag, in place of a task class.
+ *
+ * Pass one to [DagDef.task] and the Java runtime runs it as Airflow's
+ * `TriggerDagRunOperator` does, so the Dag needs no Python worker for it:
+ *
+ * ```java
+ * dag.task("trigger_downstream",
+ *     new TriggerDagRun("downstream_etl")
+ *         .config("wait_for_completion", true)
+ *         .config("conf", Map.of("rows", 2)));
+ * ```
+ *
+ * In an annotated Dag, a `@Builder.Task` method returns one instead; the
+ * method runs when the Dag is built, not when the task runs.
+ *
+ * The task runs no Java code, takes no arguments and pushes no result other
+ * than the triggered run's ID. It renders no templates, so a value such as
+ * `"{{ ds }}"` reaches the new run unchanged.
+ *
+ * @param dagId `trigger_dag_id`: the Dag to trigger.
+ * @throws IllegalArgumentException if [dagId] is empty.
+ */
+class TriggerDagRun(
+  val dagId: String,
+) {
+  init {
+    require(dagId.isNotEmpty()) { "TriggerDagRun needs the ID of the Dag to 
trigger" }
+  }
+
+  internal val settings = linkedMapOf<String, Any>()
+
+  /**
+   * Sets one `TriggerDagRunOperator` setting, named as Python names the
+   * parameter.
+   *
+   * | Key | Value |
+   * | --- | --- |
+   * | `trigger_run_id` | `String`; generated from the trigger time when unset 
|
+   * | `conf` | `Map` with string keys and JSON values |
+   * | `logical_date` | [OffsetDateTime] or [Instant]; the trigger time when 
unset |

Review Comment:
   When only `run_after` is set, the run gets no logical date 
(`TriggerRunner.kt:78-83`, same as Python), and `trigger_run_id` on line 98 is 
built from `run_after` plus a random 8-character suffix rather than from the 
trigger time. Lines 76-77 also say the task pushes only the run ID, but it 
pushes `_link_TriggerDagRunLink` too. The code looks right; it's just the docs.



##########
java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/TriggerRunner.kt:
##########
@@ -0,0 +1,318 @@
+/*
+ * 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.
+ */
+
+// Mirrors TriggerDagRunOperator.execute and execute_complete in the standard
+// provider, as the Go and TypeScript SDK runtimes do.

Review Comment:
   On this base the Go runtime doesn't run trigger tasks itself; 
`go-sdk/airflow/bundle.go` hands them to a Python worker, and the Go runtime 
version is still open in #74417. Maybe just say TypeScript here?



##########
java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Serde.kt:
##########
@@ -165,6 +170,105 @@ private fun serializeTask(
   )
 }
 
+/** What Python's serializer writes for a `TriggerDagRunOperator`. */
+private const val TRIGGER_TASK_TYPE = "TriggerDagRunOperator"
+private const val TRIGGER_TASK_MODULE = 
"airflow.providers.standard.operators.trigger_dagrun"
+private const val TRIGGER_UI_COLOR = "#ffefeb"
+
+private val TRIGGER_TEMPLATE_FIELDS =
+  listOf(
+    "trigger_dag_id",
+    "trigger_run_id",
+    "logical_date",
+    "conf",
+    "wait_for_completion",
+    "skip_when_already_exists",
+  )
+
+/**
+ * Writes a task declared from a [TriggerDagRun] as Python writes a
+ * `TriggerDagRunOperator`, so the Airflow UI and the Dag dependency graph
+ * treat it as one.
+ *
+ * Python writes an operator's template fields and leaves its other parameters
+ * out, because a Python Dag file holds them. A Java Dag has no such file, so
+ * every setting the author made is written under the name Python's parameter
+ * carries. The runtime runs the task from the Dag in its own bundle and reads
+ * none of this back.
+ */
+private fun writeTriggerDagRun(
+  data: MutableMap<String, Any?>,
+  trigger: TriggerDagRun,
+) {
+  data["task_type"] = TRIGGER_TASK_TYPE
+  data["_task_module"] = TRIGGER_TASK_MODULE
+  data["ui_color"] = TRIGGER_UI_COLOR
+  data["template_fields"] = TRIGGER_TEMPLATE_FIELDS
+  data["template_fields_renderers"] = mapOf("conf" to "py")
+  data["_operator_extra_links"] = mapOf("Triggered DAG" to 
"_link_TriggerDagRunLink")
+  data["trigger_dag_id"] = trigger.dagId
+
+  val settings = trigger.settings
+  // Python's logical_date defaults to NOTSET, which lets the operator pick the
+  // trigger time, and writes that sentinel as its name. A template field holds
+  // a datetime as str(datetime), not as a {"__type": "datetime"} object.
+  data["logical_date"] = settings["logical_date"]?.let { pythonStr(it) } ?: 
"NOTSET"
+  // Python writes these two template fields whatever they hold.
+  data["wait_for_completion"] = settings["wait_for_completion"] ?: false
+  data["skip_when_already_exists"] = settings["skip_when_already_exists"] ?: 
false
+  settings["trigger_run_id"]?.let { data["trigger_run_id"] = it }
+  settings["conf"]?.let { data["conf"] = plainJson(it, "conf", trigger.dagId) }

Review Comment:
   A non-JSON value inside `conf` gets past `config()` (which only checks that 
keys are strings) and fails here instead, with `trigger.dagId` passed as the 
task ID. The import error then reads "Argument 'conf' of task 
'downstream_etl'...", naming the target Dag as a task and talking about call 
arguments. Could `config()` run the JSON check, so it fails where the KDoc says 
it does?



##########
scripts/ci/lang_sdk_serialization/test_dags.yaml:
##########
@@ -305,7 +305,7 @@ dags:
           trigger_dag_id: conformance_minimal
           trigger_run_id: "triggered_{{ run_id }}"
           conf: {rows: 2, ratio: 0.5, nested: {tags: [a, b]}}
-          logical_date: "{{ ds }}"
+          logical_date: !datetime 2026-09-30T00:00:00+00:00

Review Comment:
   With this line, Static checks on this PR fails in 
`check-go-sdk-serialization-conformance`: `conformance_trigger_dag_run: task 
trigger_all: logical_date is '2026-09-30T00:00:00+00:00', Python writes 
'2026-09-30 00:00:00+00:00'`. If this lands before #74417, that hook stays red 
on main for every later go-sdk or fixture change. Can #74417 go first, or this 
line move into it so the fixture and the Go fix land together?



##########
java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/DagDef.kt:
##########
@@ -404,6 +429,20 @@ class TaskDef(
   val id: String,
   val definition: Class<out Task>,
 ) : Endpoint {
+  /**
+   * Declares a task that starts a run of another Dag instead of running Java
+   * code.
+   *
+   * [DagDef.task] is the spelling user code uses; this is what a generated
+   * wiring view calls.
+   *
+   * @param id Task identifier, unique within a [DagDef].
+   * @param trigger What to trigger, and how.
+   */
+  constructor(id: String, trigger: TriggerDagRun) : this(id, 
TriggerDagRunPlaceholder::class.java) {
+    this.trigger = trigger

Review Comment:
   This keeps the caller's `TriggerDagRun` by reference, and `config()` mutates 
it and returns `this`. Reusing one builder for two tasks, or calling `config()` 
on it after `dag.task(...)`, silently changes the task that's already 
registered. Go's `copyTriggerDagRunSpec` deep-copies for this reason. Could 
this snapshot `settings` (with a copy of `conf`) at registration?



##########
java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/TriggerRunnerTest.kt:
##########
@@ -0,0 +1,432 @@
+/*
+ * 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.airflow.sdk.execution
+
+import org.apache.airflow.sdk.Client
+import org.apache.airflow.sdk.TriggerDagRun
+import org.apache.airflow.sdk.execution.comm.DagRun
+import org.apache.airflow.sdk.execution.comm.DeferTask
+import org.apache.airflow.sdk.execution.comm.RetryTask
+import org.apache.airflow.sdk.execution.comm.StartupDetails
+import org.apache.airflow.sdk.execution.comm.SucceedTask
+import org.apache.airflow.sdk.execution.comm.TIRunContext
+import org.apache.airflow.sdk.execution.comm.TaskInstance
+import org.apache.airflow.sdk.execution.comm.TaskState
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertInstanceOf
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.DisplayName
+import org.junit.jupiter.api.Test
+import java.time.Duration
+import java.time.OffsetDateTime
+import java.util.UUID
+
+/** One call to [FakeTrigger.triggerDagRun], so a test can read what the task 
asked Airflow for. */
+private class TriggerCall(
+  val dagId: String,
+  val runId: String,
+  val logicalDate: OffsetDateTime?,
+  val runAfter: OffsetDateTime?,
+  val conf: Map<String, Any?>?,
+  val resetDagRun: Boolean,
+  val note: String?,
+)
+
+private class FakeTrigger(
+  private val alreadyExists: Boolean = false,
+  private val paused: Boolean = false,
+  private val states: List<String> = emptyList(),
+) : org.apache.airflow.sdk.execution.Client {
+  val triggered = mutableListOf<TriggerCall>()
+  val xComs = mutableListOf<Pair<String, Any>>()
+  var polls = 0
+
+  override fun triggerDagRun(
+    dagId: String,
+    runId: String,
+    logicalDate: OffsetDateTime?,
+    runAfter: OffsetDateTime?,
+    conf: Map<String, Any?>?,
+    resetDagRun: Boolean,
+    note: String?,
+  ): Boolean {
+    triggered += TriggerCall(dagId, runId, logicalDate, runAfter, conf, 
resetDagRun, note)
+    return alreadyExists
+  }
+
+  override fun getDagRunState(
+    dagId: String,
+    runId: String,
+  ): String = states[polls++.coerceAtMost(states.lastIndex)]

Review Comment:
   This fake ignores `dagId` and `runId`, so polling the wrong run would stay 
green. Could it assert they match what `triggerDagRun` received? A few branches 
are also only tested on one side: `deferrable` left unset and following the env 
default, the queue staying null when triggerer queues are off, and 
`fail_when_dag_is_paused` set on an unpaused Dag.



##########
java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/TriggerRunner.kt:
##########
@@ -0,0 +1,318 @@
+/*
+ * 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.
+ */
+
+// Mirrors TriggerDagRunOperator.execute and execute_complete in the standard
+// provider, as the Go and TypeScript SDK runtimes do.
+
+package org.apache.airflow.sdk.execution
+
+import org.apache.airflow.sdk.Client
+import org.apache.airflow.sdk.TriggerDagRun
+import org.apache.airflow.sdk.execution.comm.DeferTask
+import org.apache.airflow.sdk.execution.comm.StartupDetails
+import org.apache.airflow.sdk.execution.comm.TaskState
+import java.security.SecureRandom
+import java.time.Duration
+import java.time.Instant
+import java.time.OffsetDateTime
+import java.time.ZoneOffset
+import java.time.format.DateTimeFormatter
+import kotlin.random.asKotlinRandom
+
+/** `DagStateTrigger`, the Python triggerer runs the wait of a deferred task 
with. */
+private const val DAG_STATE_TRIGGER = 
"airflow.providers.standard.triggers.external_task.DagStateTrigger"
+
+/** `TriggerDagRunLink().xcom_key`: the XCom the "Triggered DAG" extra link 
reads. */
+private const val LINK_XCOM_KEY = "_link_TriggerDagRunLink"
+private const val RUN_ID_XCOM_KEY = "trigger_run_id"
+
+/** `TRIGGER_FAIL_REPR`: the `next_method` a failed or timed-out trigger 
resumes with. */
+private const val TRIGGER_FAIL = "__fail__"
+private const val EXECUTE_COMPLETE = "execute_complete"
+
+private val DEFAULT_POKE_INTERVAL = Duration.ofSeconds(60)
+
+internal object TriggerRunner {
+  private val logger = Logger(TriggerRunner::class)
+
+  /**
+   * Runs a task declared from a [TriggerDagRun], and reports the state it
+   * leaves the task instance in.
+   *
+   * A task resumed by the triggerer reads its outcome from the event the
+   * trigger fired with, rather than triggering a second run.
+   */
+  fun run(
+    trigger: TriggerDagRun,
+    request: StartupDetails,
+    client: Client,
+    env: (String) -> String? = System::getenv,
+    sleep: (Duration) -> Unit = { Thread.sleep(it.toMillis()) },
+    now: () -> OffsetDateTime = { OffsetDateTime.now(ZoneOffset.UTC) },
+    randomSuffix: () -> String = ::randomRunIdSuffix,
+  ): Any {
+    val nextMethod = request.tiContext?.nextMethod as String?
+    if (nextMethod != null) return resume(trigger, request, nextMethod)
+
+    val runAfter = asOffsetDateTime(trigger.settings["run_after"])
+    // `DagRun.generate_run_id`: a run the task gave only a run_after has no 
logical date, and is
+    // named after run_after plus a random suffix. Otherwise the logical date 
names it, and is the
+    // trigger time when the task set none.
+    val configuredLogicalDate = 
asOffsetDateTime(trigger.settings["logical_date"])
+    val logicalDate =
+      when {
+        configuredLogicalDate != null -> configuredLogicalDate
+        runAfter == null -> now()
+        else -> null
+      }
+    val namesRun = runAfter ?: logicalDate!!
+    val runId =
+      trigger.setting<String>("trigger_run_id")

Review Comment:
   `?:` only falls back on null. Python uses `if self.trigger_run_id:`, so an 
empty string means "generate one" there, while here `""` goes out as the run 
ID. `?.takeIf { it.isNotEmpty() }`, or rejecting `""` in `config()` the way the 
constructor rejects an empty `dagId`, would match. Same for `note`.



##########
java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/TriggerDagRun.kt:
##########
@@ -0,0 +1,202 @@
+/*
+ * 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.airflow.sdk
+
+import java.time.Duration
+import java.time.Instant
+import java.time.OffsetDateTime
+
+/** A Dag run state, as `allowed_states` and `failed_states` name one. */
+internal val DAG_RUN_STATES = listOf("queued", "running", "success", "failed")
+
+/** Shape of one `TriggerDagRun` setting, for [TriggerDagRun.config] to check 
a value against. */
+private enum class Setting {
+  STRING,
+  BOOLEAN,
+  DURATION,
+  DATETIME,
+  STATES,
+  CONF,
+}
+
+/**
+ * Settings named as `TriggerDagRunOperator` names its parameters. The Dag ID
+ * is the constructor argument, so `trigger_dag_id` is not among them.
+ */
+private val SETTINGS =
+  linkedMapOf(
+    "trigger_run_id" to Setting.STRING,
+    "conf" to Setting.CONF,
+    "logical_date" to Setting.DATETIME,
+    "run_after" to Setting.DATETIME,
+    "reset_dag_run" to Setting.BOOLEAN,
+    "wait_for_completion" to Setting.BOOLEAN,
+    "poke_interval" to Setting.DURATION,
+    "allowed_states" to Setting.STATES,
+    "failed_states" to Setting.STATES,
+    "skip_when_already_exists" to Setting.BOOLEAN,
+    "fail_when_dag_is_paused" to Setting.BOOLEAN,
+    "note" to Setting.STRING,
+    "deferrable" to Setting.BOOLEAN,
+  )
+
+/**
+ * A task that starts a run of another Dag, in place of a task class.
+ *
+ * Pass one to [DagDef.task] and the Java runtime runs it as Airflow's
+ * `TriggerDagRunOperator` does, so the Dag needs no Python worker for it:
+ *
+ * ```java
+ * dag.task("trigger_downstream",
+ *     new TriggerDagRun("downstream_etl")
+ *         .config("wait_for_completion", true)
+ *         .config("conf", Map.of("rows", 2)));
+ * ```
+ *
+ * In an annotated Dag, a `@Builder.Task` method returns one instead; the
+ * method runs when the Dag is built, not when the task runs.
+ *
+ * The task runs no Java code, takes no arguments and pushes no result other
+ * than the triggered run's ID. It renders no templates, so a value such as
+ * `"{{ ds }}"` reaches the new run unchanged.
+ *
+ * @param dagId `trigger_dag_id`: the Dag to trigger.
+ * @throws IllegalArgumentException if [dagId] is empty.
+ */
+class TriggerDagRun(
+  val dagId: String,
+) {
+  init {
+    require(dagId.isNotEmpty()) { "TriggerDagRun needs the ID of the Dag to 
trigger" }
+  }
+
+  internal val settings = linkedMapOf<String, Any>()
+
+  /**
+   * Sets one `TriggerDagRunOperator` setting, named as Python names the
+   * parameter.
+   *
+   * | Key | Value |
+   * | --- | --- |
+   * | `trigger_run_id` | `String`; generated from the trigger time when unset 
|
+   * | `conf` | `Map` with string keys and JSON values |
+   * | `logical_date` | [OffsetDateTime] or [Instant]; the trigger time when 
unset |
+   * | `run_after` | [OffsetDateTime] or [Instant] |
+   * | `reset_dag_run` | `Boolean`: clear a run with the same ID instead of 
failing |
+   * | `wait_for_completion` | `Boolean`: hold this task open until the run 
finishes |
+   * | `poke_interval` | [Duration], whole seconds; 60 seconds when unset |
+   * | `allowed_states` | states that count as success; `success` when unset |
+   * | `failed_states` | states that count as failure; `failed` when unset |
+   * | `skip_when_already_exists` | `Boolean`: skip rather than fail when the 
run exists |
+   * | `fail_when_dag_is_paused` | `Boolean`: fail rather than trigger a 
paused Dag |
+   * | `note` | `String` recorded against the triggered run |
+   * | `deferrable` | `Boolean`: while waiting, defer instead of holding the 
worker |
+   *
+   * A `deferrable` left unset follows Airflow's `[operators] 
default_deferrable`.
+   *
+   * @param key One of the keys above.
+   * @param value Value of the shape that key takes.
+   * @return This task, for chaining.
+   * @throws IllegalArgumentException if the key is unknown or the value does
+   *    not match it.
+   */
+  fun config(

Review Comment:
   The 13 keys here are a closed set, but `.config("poke_interval", 30)` or 
`.config("waitForCompletion", true)` compiles and only throws once the Dag is 
built. Go (`TriggerDagRunSpec` with `[]DagRunState`) and TS both use a typed 
spec. Would typed setters fit better, e.g. `new 
TriggerDagRun("etl").waitForCompletion(true).pokeInterval(Duration.ofSeconds(30)).allowedStates(DagRunState.SUCCESS)`?
 I see `DagDef.config` and `TaskDef.config` take string keys, but those come 
from the schema, and this is cheapest to change before a release.



##########
java-sdk/processor/src/main/kotlin/org/apache/airflow/sdk/BuilderProcessor.kt:
##########
@@ -439,7 +442,13 @@ class BuilderProcessor : AbstractProcessor() {
     val taskDef =
       CodeBlock
         .builder()
-        .add($$"new $T($S, $L.class)", TASK_DEF_TYPE, decl.id, classRef)
+        .apply {
+          if (decl.kind == TaskKind.TRIGGER) {
+            add($$"new $T($S, new $T().$L())", TASK_DEF_TYPE, decl.id, 
ClassName.get(decl.owner), decl.method.simpleName)

Review Comment:
   If a trigger method declares a checked exception (say it reads conf from a 
file and throws `IOException`), this generated call has no `throws` and javac 
reports "unreported exception" in a file the author never wrote. `depends()` 
already gets a named check via `isUnchecked`; the same check on trigger methods 
would give a readable error.



##########
java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Serde.kt:
##########
@@ -165,6 +170,105 @@ private fun serializeTask(
   )
 }
 
+/** What Python's serializer writes for a `TriggerDagRunOperator`. */
+private const val TRIGGER_TASK_TYPE = "TriggerDagRunOperator"
+private const val TRIGGER_TASK_MODULE = 
"airflow.providers.standard.operators.trigger_dagrun"
+private const val TRIGGER_UI_COLOR = "#ffefeb"
+
+private val TRIGGER_TEMPLATE_FIELDS =
+  listOf(
+    "trigger_dag_id",
+    "trigger_run_id",
+    "logical_date",
+    "conf",
+    "wait_for_completion",
+    "skip_when_already_exists",
+  )
+
+/**
+ * Writes a task declared from a [TriggerDagRun] as Python writes a
+ * `TriggerDagRunOperator`, so the Airflow UI and the Dag dependency graph
+ * treat it as one.
+ *
+ * Python writes an operator's template fields and leaves its other parameters
+ * out, because a Python Dag file holds them. A Java Dag has no such file, so
+ * every setting the author made is written under the name Python's parameter
+ * carries. The runtime runs the task from the Dag in its own bundle and reads
+ * none of this back.
+ */
+private fun writeTriggerDagRun(
+  data: MutableMap<String, Any?>,
+  trigger: TriggerDagRun,
+) {
+  data["task_type"] = TRIGGER_TASK_TYPE
+  data["_task_module"] = TRIGGER_TASK_MODULE
+  data["ui_color"] = TRIGGER_UI_COLOR
+  data["template_fields"] = TRIGGER_TEMPLATE_FIELDS
+  data["template_fields_renderers"] = mapOf("conf" to "py")
+  data["_operator_extra_links"] = mapOf("Triggered DAG" to 
"_link_TriggerDagRunLink")
+  data["trigger_dag_id"] = trigger.dagId
+
+  val settings = trigger.settings
+  // Python's logical_date defaults to NOTSET, which lets the operator pick the
+  // trigger time, and writes that sentinel as its name. A template field holds
+  // a datetime as str(datetime), not as a {"__type": "datetime"} object.
+  data["logical_date"] = settings["logical_date"]?.let { pythonStr(it) } ?: 
"NOTSET"
+  // Python writes these two template fields whatever they hold.
+  data["wait_for_completion"] = settings["wait_for_completion"] ?: false
+  data["skip_when_already_exists"] = settings["skip_when_already_exists"] ?: 
false
+  settings["trigger_run_id"]?.let { data["trigger_run_id"] = it }
+  settings["conf"]?.let { data["conf"] = plainJson(it, "conf", trigger.dagId) }
+  // A field that is not a template field is read back with
+  // BaseSerialization.deserialize, which returns a datetime only for the
+  // type-encoded form.
+  settings["run_after"]?.let { data["run_after"] = serializeValue(it) }
+  settings["reset_dag_run"]?.takeIf { it == true }?.let { 
data["reset_dag_run"] = true }
+  settings["poke_interval"]?.let { data["poke_interval"] = (it as 
Duration).seconds.toInt() }
+  settings["allowed_states"]?.let { data["allowed_states"] = it }
+  settings["failed_states"]?.let { data["failed_states"] = it }
+  settings["fail_when_dag_is_paused"]?.takeIf { it == true }?.let { 
data["fail_when_dag_is_paused"] = true }
+  settings["note"]?.let { data["note"] = it }
+  settings["deferrable"]?.let { data["deferrable"] = it }
+}
+
+/** `str(datetime)` in UTC, which is how Python writes a datetime in a 
template field. */
+private fun pythonStr(value: Any): String {

Review Comment:
   This and `pythonIsoformat` in `TriggerRunner.kt` differ only in the 
separator. Could `pythonIsoformat` take a `sep` and replace this one, so a fix 
to the micros or offset handling can't land in only one of them?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to