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 7aa410686c4 [v3-4-test] Load the serialized Dag in ti_run only for 
stub tasks (#75795) (#75802)
7aa410686c4 is described below

commit 7aa410686c4d5fc3bf11ce6ad6ce012f487c39a5
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Wed Oct 28 15:08:18 2026 +0800

    [v3-4-test] Load the serialized Dag in ti_run only for stub tasks (#75795) 
(#75802)
    
    * [v3-4-test] Load the serialized Dag in ti_run only for stub tasks (#75795)
    
    Starting any task deserialized its whole Dag so the route could find out
    whether the task was a stub and read its arg_bindings. Denormalize the
    serialized is_stub flag onto task_instance, which ti_run already fetches
    and locks, and load the Dag only when it is set.
    
    * Mark a Go or Java TriggerDagRun task a stub, as TypeScript does
    
    * Inline the table name in the is_stub migration
    
    * Starve a Dag the scheduler cannot deserialize
    
    test_find_executable_task_instances_skips_dag_that_cannot_be_deserialized
    fails on v3-4-test independently of this backport. #75745 brought the guard
    over from #75263 without the starved set that #75263 relies on, because that
    set came with #70307, which is not on this branch. The guard then returns
    False without narrowing the next query, so a cycle whose batch is filled by
    the bad Dag ends with nothing queued.
    
    ---------
---
 airflow-core/docs/migrations-ref.rst               |  4 +-
 .../execution_api/routes/task_instances.py         |  9 +++-
 .../src/airflow/jobs/scheduler_job_runner.py       |  7 ++-
 .../0149_3_4_0_add_is_stub_to_task_instance.py     | 54 +++++++++++++++++++++
 airflow-core/src/airflow/models/taskinstance.py    |  7 +++
 airflow-core/src/airflow/utils/db.py               |  2 +-
 .../versions/head/test_task_instances.py           | 45 ++++++++++++++++++
 .../lang_sdk_fixtures/go_native.json               |  2 +
 .../lang_sdk_fixtures/java_native.json             |  3 ++
 .../tests/unit/dag_processing/test_dagbag.py       |  9 ++++
 .../tests/unit/models/test_taskinstance.py         | 55 ++++++++++++++++++++++
 go-sdk/airflow/serialize.go                        |  9 ++--
 go-sdk/airflow/serialize_test.go                   |  3 +-
 .../org/apache/airflow/sdk/execution/Serde.kt      | 11 +++--
 .../org/apache/airflow/sdk/TriggerDagRunTest.kt    |  5 +-
 ts-sdk/src/coordinator/serde.ts                    |  8 ++--
 16 files changed, 213 insertions(+), 20 deletions(-)

diff --git a/airflow-core/docs/migrations-ref.rst 
b/airflow-core/docs/migrations-ref.rst
index 690ee0fc869..53476a7ac32 100644
--- a/airflow-core/docs/migrations-ref.rst
+++ b/airflow-core/docs/migrations-ref.rst
@@ -39,7 +39,9 @@ Here's the list of all the Database Migrations that are 
executed via when you ru
 
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
 | Revision ID             | Revises ID       | Airflow Version   | Description 
                                                 |
 
+=========================+==================+===================+==============================================================+
-| ``ee86eed19e24`` (head) | ``9cffe90fa0c0`` | ``3.4.0``         | Add 
pending_partition_key to asset_partition_dag_run and     |
+| ``b4f1a7d2c903`` (head) | ``ee86eed19e24`` | ``3.4.0``         | Add is_stub 
to task_instance.                                |
++-------------------------+------------------+-------------------+--------------------------------------------------------------+
+| ``ee86eed19e24``        | ``9cffe90fa0c0`` | ``3.4.0``         | Add 
pending_partition_key to asset_partition_dag_run and     |
 |                         |                  |                   | enforce 
single pending row per key.                          |
 
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
 | ``9cffe90fa0c0``        | ``a5d7b9c13e40`` | ``3.4.0``         | Add 
commit_allows_update to connection_test_request.         |
diff --git 
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py 
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
index d69e51cde62..bb3f8fdf20d 100644
--- 
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
+++ 
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
@@ -192,6 +192,7 @@ def ti_run(
             TI.unixname,
             TI.pid,
             TI.dag_version_id,
+            TI.is_stub,
             # This selects the raw JSON value, bypassing the deserialization 
-- we want that to happen on the
             # client
             column("next_kwargs", JSON),
@@ -379,8 +380,12 @@ def ti_run(
 
         # Only set for lang-SDK (foreign-runtime) tasks with a captured 
TaskFlow arg
         # spec; the route excludes unset fields, keeping regular responses 
lean.
-        if client_supports_arg_bindings() and (
-            arg_bindings := get_arg_bindings(dag_bag, ti, session=session)
+        # ti.is_stub carries the row's own copy of the serialized flag and is 
checked first, so the
+        # normal Python task path never loads the serialized Dag.
+        if (
+            ti.is_stub
+            and client_supports_arg_bindings()
+            and (arg_bindings := get_arg_bindings(dag_bag, ti, 
session=session))
         ):
             try:
                 context.arg_bindings = 
get_arg_bindings_adapter().validate_python(arg_bindings)
diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py 
b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
index 91304448aee..4597b2614da 100644
--- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
+++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
@@ -583,6 +583,7 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
         task_instance: TI,
         concurrency_map: ConcurrencyMap,
         session: Session,
+        starved_dags: set[str],
         starved_tasks: set[tuple[str, str]],
         starved_tasks_task_dagrun_concurrency: set[tuple[str, str, str]],
     ) -> bool:
@@ -601,8 +602,11 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
             raise
         except Exception:
             # A single Dag that fails to deserialize must not stop the 
scheduler from
-            # scheduling other Dags; skip this task instance instead of 
crashing the loop.
+            # scheduling other Dags; skip this task instance instead of 
crashing the loop. Starving
+            # the Dag keeps the next query of this cycle from handing back the 
same task instance,
+            # which would end the cycle with nothing queued whenever a bad Dag 
fills the batch.
             self.log.exception("Failed to load Dag %s for run %s, skipping 
it", dag_id, run_id)
+            starved_dags.add(dag_id)
             return False
 
         # If the DAG is missing, fail all scheduled TIs for this DAG.
@@ -921,6 +925,7 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
                         task_instance=task_instance,
                         concurrency_map=concurrency_map,
                         session=session,
+                        starved_dags=starved_dags,
                         starved_tasks=starved_tasks,
                         
starved_tasks_task_dagrun_concurrency=(starved_tasks_task_dagrun_concurrency),
                     )
diff --git 
a/airflow-core/src/airflow/migrations/versions/0149_3_4_0_add_is_stub_to_task_instance.py
 
b/airflow-core/src/airflow/migrations/versions/0149_3_4_0_add_is_stub_to_task_instance.py
new file mode 100644
index 00000000000..48cc4c5870e
--- /dev/null
+++ 
b/airflow-core/src/airflow/migrations/versions/0149_3_4_0_add_is_stub_to_task_instance.py
@@ -0,0 +1,54 @@
+#
+# 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.
+
+"""
+Add is_stub to task_instance.
+
+Revision ID: b4f1a7d2c903
+Revises: ee86eed19e24
+Create Date: 2026-10-27 09:12:44.317602
+
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+
+from airflow.migrations.utils import sqlite_rebuilds
+
+# revision identifiers, used by Alembic.
+revision = "b4f1a7d2c903"
+down_revision = "ee86eed19e24"
+branch_labels = None
+depends_on = None
+airflow_version = "3.4.0"
+
+
+def upgrade():
+    """Add is_stub to task_instance."""
+    with op.batch_alter_table("task_instance") as batch_op:
+        batch_op.add_column(sa.Column("is_stub", sa.Boolean(), nullable=True))
+
+
+def downgrade():
+    """Remove is_stub from task_instance."""
+    # Dropping a column rebuilds the table on SQLite, unlike adding one, so 
the foreign keys of
+    # everything that points at task_instance have to be held off while the 
rows are copied.
+    with sqlite_rebuilds(op), op.batch_alter_table("task_instance") as 
batch_op:
+        batch_op.drop_column("is_stub")
diff --git a/airflow-core/src/airflow/models/taskinstance.py 
b/airflow-core/src/airflow/models/taskinstance.py
index ca06135a3f5..6905ae965d8 100644
--- a/airflow-core/src/airflow/models/taskinstance.py
+++ b/airflow-core/src/airflow/models/taskinstance.py
@@ -1141,6 +1141,11 @@ class TaskInstance(Base, LoggingMixin, BaseWorkload):
     priority_weight: Mapped[int | None] = mapped_column(Integer, nullable=True)
     operator: Mapped[str | None] = mapped_column(String(1000), nullable=True)
     custom_operator_name: Mapped[str | None] = mapped_column(String(1000), 
nullable=True)
+    # Denormalized from the task so ti_run can tell a stub (foreign-runtime) 
task from a regular one
+    # without loading the serialized Dag. Null on rows written before the 
column existed. A row moved
+    # to a new Dag version without being refreshed from its task keeps the 
value it was created with,
+    # which fails closed: a task that has become a stub loses its arg_bindings 
rather than running wrong.
+    is_stub: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
     queued_dttm: Mapped[datetime | None] = mapped_column(UtcDateTime, 
nullable=True)
     scheduled_dttm: Mapped[datetime | None] = mapped_column(UtcDateTime, 
nullable=True)
     queued_by_job_id: Mapped[int | None] = mapped_column(Integer, 
nullable=True)
@@ -1385,6 +1390,7 @@ class TaskInstance(Base, LoggingMixin, BaseWorkload):
             "executor_config": task.executor_config,
             "operator": task.task_type,
             "custom_operator_name": getattr(task, "operator_name", None),
+            "is_stub": task.is_stub,
             "region_index": region_index,
             "region_id": region_id,
             "_task_display_property_value": task.task_display_name,
@@ -1581,6 +1587,7 @@ class TaskInstance(Base, LoggingMixin, BaseWorkload):
         self.operator = task.task_type
         op_name = getattr(task, "operator_name", None)
         self.custom_operator_name = op_name if isinstance(op_name, str) else ""
+        self.is_stub = task.is_stub
         # Re-apply cluster policy here so that task default do not overload 
previous data
         task_instance_mutation_hook(self, dag_run=dag_run)
 
diff --git a/airflow-core/src/airflow/utils/db.py 
b/airflow-core/src/airflow/utils/db.py
index b4a1638422f..61e80cbb6a6 100644
--- a/airflow-core/src/airflow/utils/db.py
+++ b/airflow-core/src/airflow/utils/db.py
@@ -117,7 +117,7 @@ _REVISION_HEADS_MAP: dict[str, str] = {
     "3.1.8": "509b94a1042d",
     "3.2.0": "1d6611b6ab7c",
     "3.3.0": "d2f4e1b3c5a7",
-    "3.4.0": "ee86eed19e24",
+    "3.4.0": "b4f1a7d2c903",
 }
 
 # Prefix used to identify tables holding data moved during migration.
diff --git 
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
 
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
index 391d55aeed8..9ff1bb8433d 100644
--- 
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
+++ 
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
@@ -1081,6 +1081,49 @@ class TestTIRunState:
         assert response.status_code == 200
         assert "arg_bindings" not in response.json()
 
+    def test_ti_run_does_not_load_the_dag_for_a_regular_task(self, client, 
dag_maker, mocker):
+        """The row rules the task out, so starting it reads no serialized Dag 
-- the point of the column."""
+        with dag_maker("test_regular_task_arg_bindings", serialized=True):
+
+            @task
+            def transform(country: str): ...
+
+            transform("uk")
+
+        dr = dag_maker.create_dagrun()
+        (ti,) = dr.get_task_instances()
+        assert ti.is_stub is False
+        ti.set_state(State.QUEUED)
+        dag_maker.session.flush()
+        get_dag = mocker.patch.object(DBDagBag, "get_dag", autospec=True)
+
+        response = client.patch(f"/execution/task-instances/{ti.id}/run", 
json=self.RUN_PAYLOAD)
+
+        assert response.status_code == 200
+        assert "arg_bindings" not in response.json()
+        get_dag.assert_not_called()
+
+    def test_ti_run_treats_an_unset_is_stub_as_a_regular_task(self, client, 
dag_maker):
+        """A row written before the column existed carries null and keeps its 
pre-upgrade behavior."""
+        with dag_maker("test_unset_is_stub", serialized=True):
+
+            @task.stub
+            def transform(country: str): ...
+
+            transform("uk")
+
+        dr = dag_maker.create_dagrun()
+        (ti,) = dr.get_task_instances()
+        ti.set_state(State.QUEUED)
+        dag_maker.session.get(TaskInstance, ti.id).is_stub = None
+        dag_maker.session.commit()
+        assert 
dag_maker.session.scalar(select(TaskInstance.is_stub).where(TaskInstance.id == 
ti.id)) is None
+
+        response = client.patch(f"/execution/task-instances/{ti.id}/run", 
json=self.RUN_PAYLOAD)
+
+        assert response.status_code == 200
+        assert "arg_bindings" not in response.json()
+
     @mock.patch(
         
"airflow.api_fastapi.execution_api.routes.task_instances.get_arg_bindings",
         autospec=True,
@@ -2033,6 +2076,8 @@ class TestTIRunState:
             dag_id=str(uuid4()),
         )
         ti.queued_dttm = queued_at
+        # The gate short-circuits without this, so the mocked spec below never 
reaches the route.
+        ti.is_stub = True
         session.commit()
 
         with mock.patch(
diff --git 
a/airflow-core/tests/unit/dag_processing/lang_sdk_fixtures/go_native.json 
b/airflow-core/tests/unit/dag_processing/lang_sdk_fixtures/go_native.json
index 031f73bebd1..8b440945e3b 100644
--- a/airflow-core/tests/unit/dag_processing/lang_sdk_fixtures/go_native.json
+++ b/airflow-core/tests/unit/dag_processing/lang_sdk_fixtures/go_native.json
@@ -1621,6 +1621,7 @@
               "ratio": 0.5,
               "rows": 2
             },
+            "is_stub": true,
             "logical_date": "2026-09-30 00:00:00+00:00",
             "skip_when_already_exists": true,
             "task_id": "trigger_all",
@@ -1649,6 +1650,7 @@
               "Triggered DAG": "_link_TriggerDagRunLink"
             },
             "_task_module": 
"airflow.providers.standard.operators.trigger_dagrun",
+            "is_stub": true,
             "logical_date": "NOTSET",
             "skip_when_already_exists": false,
             "task_id": "trigger_default",
diff --git 
a/airflow-core/tests/unit/dag_processing/lang_sdk_fixtures/java_native.json 
b/airflow-core/tests/unit/dag_processing/lang_sdk_fixtures/java_native.json
index 50314784b10..53f0a7256d7 100644
--- a/airflow-core/tests/unit/dag_processing/lang_sdk_fixtures/java_native.json
+++ b/airflow-core/tests/unit/dag_processing/lang_sdk_fixtures/java_native.json
@@ -27,6 +27,7 @@
                 "task_type": "DumpExtract",
                 "_task_module": "org.apache.airflow.sdk.execution",
                 "language": "java",
+                "is_stub": true,
                 "template_fields": [],
                 "retries": 2,
                 "queue": "java",
@@ -44,6 +45,7 @@
                 "task_type": "DumpLoad",
                 "_task_module": "org.apache.airflow.sdk.execution",
                 "language": "java",
+                "is_stub": true,
                 "template_fields": [],
                 "queue": "java",
                 "trigger_rule": "all_done"
@@ -112,6 +114,7 @@
                 "task_type": "DumpExtract",
                 "_task_module": "org.apache.airflow.sdk.execution",
                 "language": "java",
+                "is_stub": true,
                 "template_fields": []
               }
             }
diff --git a/airflow-core/tests/unit/dag_processing/test_dagbag.py 
b/airflow-core/tests/unit/dag_processing/test_dagbag.py
index d24443f709f..7bcd745c24c 100644
--- a/airflow-core/tests/unit/dag_processing/test_dagbag.py
+++ b/airflow-core/tests/unit/dag_processing/test_dagbag.py
@@ -1660,6 +1660,15 @@ def test_recorded_runtime_payloads_are_valid(data):
     DagSerialization.validate_serialized_dag(data)
 
 
[email protected]("data", _recorded_payloads())
+def test_recorded_runtime_payloads_mark_their_tasks_as_stubs(data):
+    """ti_run derives arg_bindings only for a stub task, and a language 
runtime runs every task of a
+    Dag defined in it, a TriggerDagRun one included, so each has to say so."""
+    dag = DagSerialization.from_dict(data)
+    for task_id, task in dag.task_dict.items():
+        assert task.is_stub, task_id
+
+
 def _serialize_native_dag(dag_id: str, fileloc: str, relative_fileloc: str) -> 
dict:
     with DAG(dag_id, schedule=None) as dag:
         BaseOperator(task_id="extract")
diff --git a/airflow-core/tests/unit/models/test_taskinstance.py 
b/airflow-core/tests/unit/models/test_taskinstance.py
index 286a04574db..958ed9c8252 100644
--- a/airflow-core/tests/unit/models/test_taskinstance.py
+++ b/airflow-core/tests/unit/models/test_taskinstance.py
@@ -2865,6 +2865,7 @@ class TestTaskInstance:
             "priority_weight": 123,
             "operator": "some_custom_operator",
             "custom_operator_name": "some_custom_operator",
+            "is_stub": False,
             "queued_dttm": run_date + datetime.timedelta(hours=1),
             "scheduled_dttm": run_date + datetime.timedelta(hours=1),
             "rendered_map_index": None,
@@ -5069,6 +5070,60 @@ class TestMakeTaskCarrier:
         assert "traceparent" in carrier
 
 
[email protected]("is_stub", [True, False])
+def test_refresh_from_task_copies_is_stub(is_stub):
+    """ti_run gates on the row, so the flag has to travel from the task to the 
TaskInstance."""
+    ser_task = create_scheduler_operator(EmptyOperator(task_id="empty"))
+    ser_task.is_stub = is_stub
+
+    ti = TI(ser_task, run_id=None, dag_version_id=mock.MagicMock())
+
+    assert ti.is_stub is is_stub
+
+
[email protected]_test
[email protected](
+    ("decorator", "expected"),
+    [pytest.param(task.stub, True, id="stub"), pytest.param(task, False, 
id="regular")],
+)
+def test_scheduled_task_instances_carry_is_stub(dag_maker, decorator, 
expected):
+    """A scheduled row carries the flag, whichever of the two write paths 
filled it in."""
+    with dag_maker("test_scheduled_is_stub", serialized=True):
+
+        @decorator
+        def transform(country: str): ...
+
+        transform("uk")
+
+    (ti,) = dag_maker.create_dagrun().get_task_instances()
+
+    assert ti.is_stub is expected
+
+
[email protected]_test
[email protected]("is_stub", [True, False])
+def test_insert_mapping_carries_is_stub(dag_maker, session, is_stub):
+    """The scheduler bulk-inserts rows from this mapping, so the flag has to 
be in it."""
+    with dag_maker("test_insert_mapping_is_stub"):
+        EmptyOperator(task_id="t1")
+    session.flush()
+
+    op = create_scheduler_operator(dag_maker.dag.get_task("t1"))
+    op.is_stub = is_stub
+    dag_run = mock.MagicMock()
+    dag_run.context_carrier = new_dagrun_trace_carrier()
+
+    mapping = TaskInstance.insert_mapping(
+        run_id="test_run",
+        task=op,
+        map_index=0,
+        dag_version_id=None,
+        dag_run=dag_run,
+    )
+
+    assert mapping["is_stub"] is is_stub
+
+
 @pytest.mark.db_test
 def test_insert_mapping_includes_context_carrier(dag_maker, session):
     """insert_mapping should include a context_carrier with a traceparent 
derived from the dag run."""
diff --git a/go-sdk/airflow/serialize.go b/go-sdk/airflow/serialize.go
index bd294b85b4f..0eec1a44a3b 100644
--- a/go-sdk/airflow/serialize.go
+++ b/go-sdk/airflow/serialize.go
@@ -424,9 +424,6 @@ func (d *DagRef) serializeTaskLocked(task *TaskRef) 
map[string]any {
                // Python writes template_fields for every operator. A Go task 
has no template fields, so the list
                // is empty.
                data["template_fields"] = []string{}
-               // is_stub makes the API server send the task its arguments 
from _arg_bindings when the
-               // task runs, as it does for a @task.stub task of a Python Dag.
-               data["is_stub"] = true
                if len(task.inputs) > 0 {
                        data["_arg_bindings"] = 
serializeArgBindings(task.inputs)
                }
@@ -437,7 +434,11 @@ func (d *DagRef) serializeTaskLocked(task *TaskRef) 
map[string]any {
                        data["_can_skip_downstream"] = true
                }
        }
-       // A task from TriggerDagRun takes the queue of the Dag too, because 
the Go runtime runs it.
+       // The Go runtime runs every task of a Go Dag, a task from 
TriggerDagRun included, so every one of
+       // them is a stub. is_stub makes the API server send the task its 
arguments from _arg_bindings when
+       // the task runs, as it does for a @task.stub task of a Python Dag.
+       data["is_stub"] = true
+       // The worker picks a runtime by queue, so a task from TriggerDagRun 
takes the queue of the Dag too.
        if spec.Queue == "" {
                spec.Queue = d.spec.Queue
        }
diff --git a/go-sdk/airflow/serialize_test.go b/go-sdk/airflow/serialize_test.go
index 4f728ada5d8..4a7bcf96dd8 100644
--- a/go-sdk/airflow/serialize_test.go
+++ b/go-sdk/airflow/serialize_test.go
@@ -925,6 +925,7 @@ func 
TestSerializeWritesATriggerDagRunAsATriggerDagRunOperator(t *testing.T) {
        assertJSON(t, `{
                "task_id": "trigger",
                "task_type": "TriggerDagRunOperator",
+               "is_stub": true,
                "_task_module": 
"airflow.providers.standard.operators.trigger_dagrun",
                "ui_color": "#ffefeb",
                "template_fields": [
@@ -1012,7 +1013,7 @@ func 
TestSerializeWritesOnlyTheTriggerDagRunOptionsThatAreSet(t *testing.T) {
                "skip_when_already_exists": false
        }`, withoutKeys(serializedTask(t, got, "trigger"),
                "task_id", "task_type", "_task_module", "ui_color", 
"template_fields",
-               "template_fields_renderers", "_operator_extra_links",
+               "template_fields_renderers", "_operator_extra_links", "is_stub",
        ))
        // An empty FailedStates means that no Dag run state fails the task.
        assertJSON(t, `[]`, serializedTask(t, got, 
"trigger_never_fails")["failed_states"])
diff --git 
a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Serde.kt 
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Serde.kt
index c01592b8eea..8a5d470426a 100644
--- a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Serde.kt
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Serde.kt
@@ -145,15 +145,16 @@ private fun serializeTask(
     // value never matches the tuple default it is compared against), so it
     // is unconditional here too. Java tasks have no template fields.
     data["template_fields"] = emptyList<Any?>()
-    // What marks a task whose arguments Airflow resolves per instance for a
-    // runtime outside Python, as `@task.stub` does on the Python side.
-    // `get_arg_bindings` reads nothing without it.
-    data["is_stub"] = true
     argBindings(taskId, def)?.let { data["_arg_bindings"] = it }
   }
+  // The Java runtime runs every task of a Java Dag, a trigger task included, 
so
+  // every one of them is a stub. is_stub marks a task whose arguments Airflow
+  // resolves per instance for a runtime outside Python, as `@task.stub` does 
on
+  // the Python side. `get_arg_bindings` reads nothing without it.
+  data["is_stub"] = true
   // Lets NotPreviouslySkippedDep re-skip a cleared downstream, as Python's 
SkipMixin does.
   if (def.decider != null) data["_can_skip_downstream"] = true
-  // A trigger task takes the Dag's queue too, because the Java runtime runs 
it.
+  // The worker picks a runtime by queue, so a trigger task takes the Dag's 
queue too.
   val config =
     if (dagQueue == null || "queue" in def.configValues) def.configValues else 
def.configValues + ("queue" to dagQueue)
   // Emit only config entries that differ from their schema default, mirroring
diff --git 
a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/TriggerDagRunTest.kt 
b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/TriggerDagRunTest.kt
index 56c0bb410b7..b7bef8724f7 100644
--- a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/TriggerDagRunTest.kt
+++ b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/TriggerDagRunTest.kt
@@ -168,9 +168,10 @@ internal class TriggerDagRunTest {
     assertEquals("NOTSET", data["logical_date"])
     assertEquals(false, data["wait_for_completion"])
     assertEquals(false, data["skip_when_already_exists"])
-    // A Java task's markers belong to a task the Java runtime runs a body for.
+    // The Java runtime runs a trigger task too, so it carries the stub marker.
+    assertEquals(true, data["is_stub"])
+    // `language` marks a task the Java runtime runs a body for, which this is 
not.
     assertFalse("language" in data)
-    assertFalse("is_stub" in data)
     assertFalse("reset_dag_run" in data)
   }
 
diff --git a/ts-sdk/src/coordinator/serde.ts b/ts-sdk/src/coordinator/serde.ts
index c528980487c..b6938fa81d6 100644
--- a/ts-sdk/src/coordinator/serde.ts
+++ b/ts-sdk/src/coordinator/serde.ts
@@ -252,9 +252,11 @@ function serializeTask(
     // matches the tuple default it is compared against. A TypeScript task has
     // no Jinja templating, so the list is empty rather than absent.
     template_fields: [],
-    // What marks a task whose arguments the API server resolves per instance
-    // and sends to a foreign runtime, as `@task.stub` does on the Python side.
-    // `get_arg_bindings` reads nothing without it.
+    // The TypeScript runtime runs every task of a TypeScript Dag, a trigger 
task
+    // included, so every one of them is a stub. `is_stub` marks a task whose
+    // arguments the API server resolves per instance and sends to a foreign
+    // runtime, as `@task.stub` does on the Python side. `get_arg_bindings` 
reads
+    // nothing without it.
     is_stub: true,
   };
   const label = `task "${taskId}" of Dag "${dagId}"`;

Reply via email to