This is an automated email from the ASF dual-hosted git repository.

jason810496 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new bc28bb0804c Go SDK: Fix the mixed language task handler binding 
semantic (#73648)
bc28bb0804c is described below

commit bc28bb0804c4c9d3fec2dc0c636e739f823a213c
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Tue Sep 29 16:40:35 2026 +0800

    Go SDK: Fix the mixed language task handler binding semantic (#73648)
    
    * Go SDK: require every struct field to bind, warn on extra arguments
    
    * Go SDK: add arg:"-" opt-out, correct the README binding rules
    
    * Go SDK: log struct name mismatches both ways instead of failing
    
    * Go SDK: name each binding mismatch direction, exercise both e2e
    
    * Go SDK: warn on an empty spec too, assert both warnings e2e
    
    * Go SDK e2e: assert binding warnings by record field
---
 .../0007-taskflow-across-language-boundary.md      |  39 ++++++++
 .../go_sdk_tests/test_go_sdk_taskflow_binding.py   |  72 ++++++++++++--
 go-sdk/README.md                                   |  14 ++-
 go-sdk/adr/0006-cross-language-argument-binding.md |  15 ++-
 .../cmd/airflow-go-pack/pack_integration_test.go   |   4 +-
 go-sdk/dags/go_examples.py                         |  28 ++++--
 go-sdk/example/bundle/main.go                      |  14 ++-
 .../bundle/taskflowbinding/taskflowbinding.go      |  61 +++++++++---
 .../bundle/taskflowbinding/taskflowbinding_test.go |  44 ++++++---
 go-sdk/pkg/binding/binding.go                      |  56 ++++++++---
 go-sdk/pkg/binding/binding_test.go                 | 103 +++++++++++++++------
 11 files changed, 360 insertions(+), 90 deletions(-)

diff --git 
a/airflow-core/adr/lang-sdk/0007-taskflow-across-language-boundary.md 
b/airflow-core/adr/lang-sdk/0007-taskflow-across-language-boundary.md
index b3dcbba997c..c1f9d06fcf6 100644
--- a/airflow-core/adr/lang-sdk/0007-taskflow-across-language-boundary.md
+++ b/airflow-core/adr/lang-sdk/0007-taskflow-across-language-boundary.md
@@ -105,6 +105,45 @@ Materializing is what makes the contract work at all: the 
spec is the *only* thi
 receives, so it must be complete and self-describing rather than a reference 
the runtime is
 expected to resolve on its own.
 
+#### What a runtime does when the two signatures disagree
+
+The spec describes the stub signature. Nothing checks it against the foreign 
handler's own
+signature, in either direction, so every SDK has to answer the same two 
questions the same way.
+"More" means the call passed arguments the handler does not take; "fewer" 
means the handler
+declared something no argument supplies.
+
+| the handler binds | more | fewer |
+| --- | --- | --- |
+| by position | fail | fail |
+| by name | log, do not fail | log, do not fail |
+
+Positional binding fails either way because positions carry the whole meaning 
of the binding: an
+argument dropped or added shifts every later one, so the handler would read 
values it has mistaken
+for others. The one exception is a trailing `from_default` entry, which the 
runtime may drop to
+match the handler's arity, since a captured default carries no intent from the 
call site.
+
+Name binding cannot shift, so neither direction is worth failing a task over: 
a field nothing fills
+takes the language's absent value, and an argument no field claims changes 
nothing the handler
+reads. Both are logged instead, before the task runs, so a mistyped name 
surfaces in the task log
+ahead of anything the task itself prints. `from_default` entries are not 
reported, since a handler
+ignoring a captured default is the normal case.
+
+A single call can be wrong in both directions at once, so each gets its own 
message rather than one
+line a reader has to untangle. SDKs implementing this use the same two 
wordings, so one query finds
+the mismatch across languages:
+
+- `Dag's call passed argument(s) the task handler does not declare`
+- `Task handler declares argument(s) the Dag's call did not pass`
+
+An SDK may log more besides. The TypeScript SDK also warns when a handler 
reads a name nothing
+bound, which is a run-time event rather than a signature mismatch and carries 
its own wording.
+
+Where the declaration comes from differs by language and is not a difference 
in the rule. Go's sole
+struct and Java's `TaskInput` are field lists the runtime can read directly. 
The TypeScript SDK has
+no such list, because parameter types are erased, so it reads the handler's 
destructuring pattern
+instead; a handler that takes the whole argument object has declared nothing 
to compare a call
+against, and nothing is reported for it.
+
 ### B. Materialization belongs in core Dag serialization, behind a generic 
`is_stub` flag
 
 The spec is built in Airflow core, from 
`OperatorSerialization._serialize_node`, for a non-mapped
diff --git 
a/airflow-e2e-tests/tests/airflow_e2e_tests/go_sdk_tests/test_go_sdk_taskflow_binding.py
 
b/airflow-e2e-tests/tests/airflow_e2e_tests/go_sdk_tests/test_go_sdk_taskflow_binding.py
index d078261f8b1..d01924994c3 100644
--- 
a/airflow-e2e-tests/tests/airflow_e2e_tests/go_sdk_tests/test_go_sdk_taskflow_binding.py
+++ 
b/airflow-e2e-tests/tests/airflow_e2e_tests/go_sdk_tests/test_go_sdk_taskflow_binding.py
@@ -26,6 +26,7 @@ the tests here check the run outcome and the summary XCom it 
pushes.
 
 from __future__ import annotations
 
+import time
 from dataclasses import dataclass
 from datetime import datetime, timezone
 
@@ -36,6 +37,9 @@ from airflow_e2e_tests.e2e_test_utils.clients import 
AirflowClient
 # Allow time for coordinator startup.
 _GO_TASK_TIMEOUT = 300
 
+# Task logs land shortly after the run finishes.
+_LOG_FETCH_TIMEOUT = 60
+
 _DAG_ID = "taskflow_binding_dag"
 
 
@@ -53,6 +57,30 @@ class _CompletedRun:
             "value"
         )
 
+    def logs(self, task_id: str, try_number: int = 1) -> str:
+        """Return the concatenated task-log records for *task_id*, retrying 
until present."""
+        deadline = time.monotonic() + _LOG_FETCH_TIMEOUT
+        while True:
+            text = "\n".join(str(entry) for entry in self.log_records(task_id, 
try_number))
+            if text.strip() or time.monotonic() > deadline:
+                return text
+            time.sleep(3)
+
+    def log_records(self, task_id: str, try_number: int = 1) -> list[dict]:
+        """Return the structured task-log records (parsed JSON dicts) for 
*task_id*."""
+        resp = self.client.get_task_logs(
+            dag_id=_DAG_ID, run_id=self.run_id, task_id=task_id, 
try_number=try_number
+        )
+        return [entry for entry in resp.get("content", []) if 
isinstance(entry, dict)]
+
+    def warning(self, task_id: str, event: str) -> dict:
+        """Return the first log record of *task_id* whose event is *event*, 
failing if none."""
+        self.logs(task_id)
+        records = self.log_records(task_id)
+        found = next((r for r in records if r.get("event") == event), None)
+        assert found is not None, [r.get("event") for r in records]
+        return found
+
 
 @pytest.fixture(scope="module")
 def completed_run() -> _CompletedRun:
@@ -79,7 +107,9 @@ def test_all_tasks_succeeded(completed_run: _CompletedRun):
         "via_flat_args",
         "via_struct_no_tags",
         "via_struct_arg_tag",
-        "via_struct_unmatched_arg",
+        "via_struct_default_arg",
+        "via_struct_more_args",
+        "via_struct_fewer_args",
         "via_flat_map",
         "via_struct_map",
         "via_plain_map",
@@ -137,17 +167,39 @@ def 
test_via_struct_arg_tag_reflects_bound_arguments(completed_run: _CompletedRu
     }
 
 
-def test_via_struct_unmatched_arg_reflects_zero_valued_field(completed_run: 
_CompletedRun):
-    """``via_struct_unmatched_arg`` demonstrates mismatch tolerance in both 
directions:
-    a struct field whose name has no corresponding TaskFlow call argument 
stays at its
-    Go zero value instead of failing the task (kwarg-style, an unpassed name 
simply
-    isn't bound), and the stub's defaulted ``sample_rate`` -- captured into 
the spec as
-    ``from_default`` -- needs no matching struct field. The task succeeding at 
all
-    proves the second half."""
-    assert completed_run.xcom("via_struct_unmatched_arg") == {
+def test_via_struct_default_arg_tolerates_unclaimed_default(completed_run: 
_CompletedRun):
+    """``via_struct_default_arg`` proves a captured stub default needs no 
struct field:
+    ``sample_rate`` is unpassed, so the spec carries it as ``from_default`` 
and no Go
+    field claims it. The task succeeding at all is the assertion."""
+    assert completed_run.xcom("via_struct_default_arg") == {"region": 
"eu-west-1"}
+
+
+def test_via_struct_more_args_warns_and_runs(completed_run: _CompletedRun):
+    """The call passes ``unused_label``, which the Go struct does not declare. 
Name
+    binding cannot shift, so the extra argument is warned about rather than 
failing
+    the task, and everything the struct does declare still binds."""
+    assert completed_run.xcom("via_struct_more_args") == {"region": 
"eu-west-1"}
+    warning = completed_run.warning(
+        "via_struct_more_args", "Dag's call passed argument(s) the task 
handler does not declare"
+    )
+    assert warning.get("passed_not_declared") == ["unused_label"], warning
+
+
+def test_via_struct_fewer_args_warns_and_runs(completed_run: _CompletedRun):
+    """The Go struct declares ``not_in_dag``, which the stub has no parameter 
for. The
+    field keeps its Go zero value and the mismatch is warned about rather than 
failing
+    the task, so the two sides can drift without breaking the Dag.
+
+    The warning is the assertion that matters: the zero-valued field alone 
would look
+    the same as the older behaviour that filled it silently."""
+    assert completed_run.xcom("via_struct_fewer_args") == {
         "region": "eu-west-1",
-        "missing_was_empty": True,
+        "not_in_dag_was_empty": True,
     }
+    warning = completed_run.warning(
+        "via_struct_fewer_args", "Task handler declares argument(s) the Dag's 
call did not pass"
+    )
+    assert warning.get("declared_not_passed") == ['NotInDag (argument 
"not_in_dag")'], warning
 
 
 def test_via_flat_map_decodes_single_dict_whole(completed_run: _CompletedRun):
diff --git a/go-sdk/README.md b/go-sdk/README.md
index 14bed678abc..e8fa38cf3e1 100644
--- a/go-sdk/README.md
+++ b/go-sdk/README.md
@@ -161,9 +161,17 @@ An exported field binds the argument matching its own Go 
name, folding case and
 tag when the names genuinely differ, as `Region` does above. Declaration order 
is irrelevant on both
 sides, and embedded structs contribute their fields just as they do to 
`encoding/json`.
 
-A field no argument matches is left at its Go zero value, like an unpassed 
keyword argument. The
-reverse is an error: every argument the Dag author explicitly passed must land 
in some field, so a
-typo'd tag fails the task instead of silently dropping the value.
+Taking **more or fewer arguments** than the Python side passes does not fail 
the task. The runtime
+logs a warning before the task runs and carries on, one message per direction, 
so a call that does
+both at once says so twice:
+
+- `Dag's call passed argument(s) the task handler does not declare`
+- `Task handler declares argument(s) the Dag's call did not pass`
+
+Name-based struct binding is what keeps a mixed-language task working while 
the two sides drift:
+adding a parameter to the stub, or dropping a field from the struct, is a 
warning rather than a
+broken Dag. A field nothing matches keeps its Go zero value. Arguments the 
call left at their stub
+default are not reported.
 
 A struct that is **not** the sole data parameter is decoded whole from its one 
positional argument
 instead, so `arg:` tags only apply to the sole-parameter form; pairing a 
tagged struct with other
diff --git a/go-sdk/adr/0006-cross-language-argument-binding.md 
b/go-sdk/adr/0006-cross-language-argument-binding.md
index cdf2f5c0060..392fbfa062c 100644
--- a/go-sdk/adr/0006-cross-language-argument-binding.md
+++ b/go-sdk/adr/0006-cross-language-argument-binding.md
@@ -108,8 +108,8 @@ ordered bindings / raw values             | named bindings 
/ raw values
 arity must match data slots               | `arg:` exact name or untagged 
folded name
 captured defaults may be dropped          |
                                           | claimed value -> field
-                                          | unmatched field -> zero value
-                                          | unclaimed explicit arg -> error
+                                          | unmatched field -> zero value + 
warning
+                                          | unclaimed explicit arg -> warning
                                           | untagged + one explicit arg + no 
field claim
                                           |   -> decode whole struct
 
@@ -135,8 +135,15 @@ captured defaults may be dropped          |
   current Dag run; independent XCom pulls run concurrently and cancel together 
on failure.
 - Captured Python defaults may remain unclaimed. Flat bindings must match the 
data-parameter
   count. A sole struct claims arguments by field name, except that one 
explicit argument may
-  decode as the whole value when the struct is untagged and no field matches. 
Remaining explicit
-  arguments and incompatible schemas or Go types fail before the task body 
runs.
+  decode as the whole value when the struct is untagged and no field matches.
+- Neither direction of a sole-struct name mismatch fails the task, following 
the cross-language
+  rule in [lang-SDK ADR 
0007](../../airflow-core/adr/lang-sdk/0007-taskflow-across-language-boundary.md):
+  an unfilled field keeps its zero value and an unclaimed explicit argument is 
ignored, and both
+  are logged. Flat bindings still fail either way, because a dropped or added 
positional argument
+  shifts every later one. Incompatible schemas or Go types still fail before 
the task body runs.
+- A spec that arrives empty is the same mismatch with every field unfilled, 
and warns like any
+  other. `build_arg_bindings` sends nothing at all for a stub called with no 
arguments, so an
+  empty spec is an ordinary argless call rather than a signal that anything is 
wrong.
 - A known JSON Schema shape is checked against the Go target type before 
strict JSON decoding.
   Missing or unknown schema forms remain unconstrained and rely on the decoder.
 - Cross-language TaskFlow binding uses the coordinator path described in
diff --git a/go-sdk/cmd/airflow-go-pack/pack_integration_test.go 
b/go-sdk/cmd/airflow-go-pack/pack_integration_test.go
index f0b735ebf2c..e1cc29fe68e 100644
--- a/go-sdk/cmd/airflow-go-pack/pack_integration_test.go
+++ b/go-sdk/cmd/airflow-go-pack/pack_integration_test.go
@@ -162,7 +162,9 @@ dags:
       - "via_flat_args"
       - "via_struct_no_tags"
       - "via_struct_arg_tag"
-      - "via_struct_unmatched_arg"
+      - "via_struct_default_arg"
+      - "via_struct_more_args"
+      - "via_struct_fewer_args"
       - "via_flat_map"
       - "via_struct_map"
       - "via_plain_map"
diff --git a/go-sdk/dags/go_examples.py b/go-sdk/dags/go_examples.py
index 46367234e4b..c87fb3a682f 100644
--- a/go-sdk/dags/go_examples.py
+++ b/go-sdk/dags/go_examples.py
@@ -151,7 +151,19 @@ def via_struct_arg_tag(region_code: str, threshold: 
float): ...
 
 
 @task.stub(queue="golang")
-def via_struct_unmatched_arg(region_code: str, sample_rate: float = 0.1): ...
+def via_struct_default_arg(region_code: str, sample_rate: float = 0.1): ...
+
+
+# The Go struct declares only region_code, so `unused_label` is passed but not
+# declared: a warning, not a failure.
[email protected](queue="golang")
+def via_struct_more_args(region_code: str, unused_label: str): ...
+
+
+# The Go struct also declares `not_in_dag`, which this signature has no
+# parameter for: declared but not passed, and it keeps its Go zero value.
[email protected](queue="golang")
+def via_struct_fewer_args(region_code: str): ...
 
 
 @task.stub(queue="golang")
@@ -173,16 +185,18 @@ def taskflow_binding_dag():
 
     A Go task declares either flat data parameters, which bind *positionally*
     (order matters, every one must be filled), or a single struct, whose fields
-    bind by *name* like keyword arguments -- an unmatched field stays at its Go
-    zero value instead of failing the task.
+    bind by *name* like keyword arguments. Name binding tolerates a signature
+    mismatch in either direction: taking more or fewer arguments than the call
+    passes is warned about rather than failing the task.
 
     * ``via_flat_args``: every scalar literal, an array literal, keyword args,
       an unpassed ``None`` default, and XComs fanned in from two upstream 
tasks.
     * ``via_struct_no_tags``: fields fall back to their own Go names, matched
       case- and underscore-insensitively.
     * ``via_struct_arg_tag``: fields bind via explicit ``arg:`` tags.
-    * ``via_struct_unmatched_arg``: a Go field no argument names, and a stub
-      default no Go field claims.
+    * ``via_struct_default_arg``: a stub default no Go field claims.
+    * ``via_struct_more_args`` / ``via_struct_fewer_args``: the two ways a 
name-bound
+      struct and the call can disagree. Each warns and runs rather than 
failing.
     * ``via_flat_map`` / ``via_struct_map`` / ``via_plain_map``: one dict bound
       whole into a struct, onto a struct's map field, and into a plain Go map.
 
@@ -202,7 +216,9 @@ def taskflow_binding_dag():
     region = make_region()
     via_struct_no_tags(region_code=region, threshold=0.75)
     via_struct_arg_tag(region_code=region, threshold=0.75)
-    via_struct_unmatched_arg(region_code=region)
+    via_struct_default_arg(region_code=region)
+    via_struct_more_args(region_code=region, unused_label="ignored")
+    via_struct_fewer_args(region_code=region)
     via_flat_map(config={"region": "eu-west-1", "count": 3})
     via_struct_map(payload={"region": "eu-west-1", "count": 3})
     via_plain_map(labels={"team": "data", "tier": "gold"})
diff --git a/go-sdk/example/bundle/main.go b/go-sdk/example/bundle/main.go
index d9bcfcfab07..00c346d420d 100644
--- a/go-sdk/example/bundle/main.go
+++ b/go-sdk/example/bundle/main.go
@@ -61,8 +61,18 @@ func main() {
                ),
                airflow.TaskHandler(
                        "taskflow_binding_dag",
-                       "via_struct_unmatched_arg",
-                       taskflowbinding.ViaStructUnmatchedArg,
+                       "via_struct_default_arg",
+                       taskflowbinding.ViaStructDefaultArg,
+               ),
+               airflow.TaskHandler(
+                       "taskflow_binding_dag",
+                       "via_struct_more_args",
+                       taskflowbinding.ViaStructMoreArgs,
+               ),
+               airflow.TaskHandler(
+                       "taskflow_binding_dag",
+                       "via_struct_fewer_args",
+                       taskflowbinding.ViaStructFewerArgs,
                ),
                airflow.TaskHandler("taskflow_binding_dag", "via_flat_map", 
taskflowbinding.ViaFlatMap),
                airflow.TaskHandler("taskflow_binding_dag", "via_struct_map", 
taskflowbinding.ViaStructMap),
diff --git a/go-sdk/example/bundle/taskflowbinding/taskflowbinding.go 
b/go-sdk/example/bundle/taskflowbinding/taskflowbinding.go
index 0b1556fca14..3d9ed2bfa4c 100644
--- a/go-sdk/example/bundle/taskflowbinding/taskflowbinding.go
+++ b/go-sdk/example/bundle/taskflowbinding/taskflowbinding.go
@@ -171,34 +171,67 @@ func ViaStructArgTag(actx airflow.Context, input 
ViaStructArgTagInput) (any, err
        }, nil
 }
 
-// ViaStructUnmatchedArgInput includes a field no argument supplies.
-type ViaStructUnmatchedArgInput struct {
-       Region  string `arg:"region_code"`
-       Missing string `arg:"does_not_exist"`
+// ViaStructDefaultArgInput claims only the explicitly passed argument.
+type ViaStructDefaultArgInput struct {
+       Region string `arg:"region_code"`
 }
 
-// ViaStructUnmatchedArg exercises unmatched fields and captured defaults.
-func ViaStructUnmatchedArg(
+// ViaStructDefaultArg exercises a captured stub default no struct field 
claims.
+func ViaStructDefaultArg(
        actx airflow.Context,
-       input ViaStructUnmatchedArgInput,
+       input ViaStructDefaultArgInput,
 ) (any, error) {
        if input.Region != "eu-west-1" {
                return nil, fmt.Errorf("struct field bound incorrectly: 
region=%q", input.Region)
        }
-       if input.Missing != "" {
+
+       actx.Logger().
+               InfoContext(actx, "Bound struct (defaulted arg unclaimed)", 
"region", input.Region)
+       return map[string]any{"region": input.Region}, nil
+}
+
+// ViaStructMoreArgsInput declares fewer fields than its call passes arguments.
+type ViaStructMoreArgsInput struct {
+       Region string `arg:"region_code"`
+}
+
+// ViaStructMoreArgs exercises the Dag passing an argument the struct does not
+// declare: warned about, and the task still runs.
+func ViaStructMoreArgs(actx airflow.Context, input ViaStructMoreArgsInput) 
(any, error) {
+       if input.Region != "eu-west-1" {
+               return nil, fmt.Errorf("struct field bound incorrectly: 
region=%q", input.Region)
+       }
+
+       actx.Logger().InfoContext(actx, "Bound struct (call passed more)", 
"region", input.Region)
+       return map[string]any{"region": input.Region}, nil
+}
+
+// ViaStructFewerArgsInput declares more fields than its call passes arguments.
+type ViaStructFewerArgsInput struct {
+       Region   string `arg:"region_code"`
+       NotInDag string `arg:"not_in_dag"`
+}
+
+// ViaStructFewerArgs exercises the struct declaring an argument the Dag's call
+// does not pass: warned about, and the field keeps its Go zero value.
+func ViaStructFewerArgs(actx airflow.Context, input ViaStructFewerArgsInput) 
(any, error) {
+       if input.Region != "eu-west-1" {
+               return nil, fmt.Errorf("struct field bound incorrectly: 
region=%q", input.Region)
+       }
+       if input.NotInDag != "" {
                return nil, fmt.Errorf(
-                       "expected the unmatched field to stay at its Go zero 
value, got missing=%q",
-                       input.Missing,
+                       "expected the undeclared field to keep its zero value, 
got not_in_dag=%q",
+                       input.NotInDag,
                )
        }
 
-       actx.Logger().InfoContext(actx, "Bound struct (unmatched arg)",
+       actx.Logger().InfoContext(actx, "Bound struct (call passed fewer)",
                "region", input.Region,
-               "missing_was_empty", input.Missing == "",
+               "not_in_dag_was_empty", input.NotInDag == "",
        )
        return map[string]any{
-               "region":            input.Region,
-               "missing_was_empty": input.Missing == "",
+               "region":               input.Region,
+               "not_in_dag_was_empty": input.NotInDag == "",
        }, nil
 }
 
diff --git a/go-sdk/example/bundle/taskflowbinding/taskflowbinding_test.go 
b/go-sdk/example/bundle/taskflowbinding/taskflowbinding_test.go
index beb046aa29a..4bf3a4bab3d 100644
--- a/go-sdk/example/bundle/taskflowbinding/taskflowbinding_test.go
+++ b/go-sdk/example/bundle/taskflowbinding/taskflowbinding_test.go
@@ -107,24 +107,44 @@ func TestViaStructArgTagRejectsWrongBinding(t *testing.T) 
{
        assert.ErrorContains(t, err, "struct fields bound incorrectly")
 }
 
-func TestViaStructUnmatchedArg(t *testing.T) {
-       got, err := ViaStructUnmatchedArg(testContext(t), 
ViaStructUnmatchedArgInput{
-               Region:  "eu-west-1",
-               Missing: "",
-       })
+func TestViaStructDefaultArg(t *testing.T) {
+       got, err := ViaStructDefaultArg(testContext(t), 
ViaStructDefaultArgInput{Region: "eu-west-1"})
+       require.NoError(t, err)
+
+       summary, ok := got.(map[string]any)
+       require.True(t, ok, "ViaStructDefaultArg should return a map summary, 
got %T", got)
+       assert.Equal(t, "eu-west-1", summary["region"])
+}
+
+func TestViaStructDefaultArgRejectsWrongBinding(t *testing.T) {
+       _, err := ViaStructDefaultArg(testContext(t), 
ViaStructDefaultArgInput{Region: "wrong-region"})
+       assert.ErrorContains(t, err, "struct field bound incorrectly")
+}
+
+func TestViaStructMoreArgs(t *testing.T) {
+       got, err := ViaStructMoreArgs(testContext(t), 
ViaStructMoreArgsInput{Region: "eu-west-1"})
+       require.NoError(t, err)
+
+       summary, ok := got.(map[string]any)
+       require.True(t, ok, "ViaStructMoreArgs should return a map summary, got 
%T", got)
+       assert.Equal(t, "eu-west-1", summary["region"])
+}
+
+func TestViaStructFewerArgs(t *testing.T) {
+       got, err := ViaStructFewerArgs(testContext(t), 
ViaStructFewerArgsInput{Region: "eu-west-1"})
        require.NoError(t, err)
 
        summary, ok := got.(map[string]any)
-       require.True(t, ok, "ViaStructUnmatchedArg should return a map summary, 
got %T", got)
-       assert.Equal(t, true, summary["missing_was_empty"])
+       require.True(t, ok, "ViaStructFewerArgs should return a map summary, 
got %T", got)
+       assert.Equal(t, true, summary["not_in_dag_was_empty"])
 }
 
-func TestViaStructUnmatchedArgRejectsNonZeroMissingField(t *testing.T) {
-       _, err := ViaStructUnmatchedArg(testContext(t), 
ViaStructUnmatchedArgInput{
-               Region:  "eu-west-1",
-               Missing: "unexpected",
+func TestViaStructFewerArgsRejectsFilledField(t *testing.T) {
+       _, err := ViaStructFewerArgs(testContext(t), ViaStructFewerArgsInput{
+               Region:   "eu-west-1",
+               NotInDag: "unexpected",
        })
-       assert.ErrorContains(t, err, "expected the unmatched field to stay at 
its Go zero value")
+       assert.ErrorContains(t, err, "expected the undeclared field to keep its 
zero value")
 }
 
 func TestViaFlatMap(t *testing.T) {
diff --git a/go-sdk/pkg/binding/binding.go b/go-sdk/pkg/binding/binding.go
index cb35a35f594..bcc3752ec9a 100644
--- a/go-sdk/pkg/binding/binding.go
+++ b/go-sdk/pkg/binding/binding.go
@@ -164,7 +164,7 @@ func (p *Plan) Resolve(
        ti, dagRun := storedRunMetadata(ctx)
        out[0] = newAirflowContext(ctx, logger, client, ti, dagRun)
        if p.loneStruct {
-               return p.resolveLoneStructParam(ctx, client, args, out)
+               return p.resolveLoneStructParam(ctx, logger, client, args, out)
        }
        return p.resolveFlatParams(ctx, client, args, out)
 }
@@ -219,6 +219,7 @@ func (p *Plan) resolveFlatParams(
 
 func (p *Plan) resolveLoneStructParam(
        ctx context.Context,
+       logger *slog.Logger,
        c sdk.XComClient,
        args []Arg,
        out []reflect.Value,
@@ -255,6 +256,7 @@ func (p *Plan) resolveLoneStructParam(
                argIdx int
        }
        binds := make([]fieldBind, 0, len(plan.fields))
+       var unfilled []string
        for _, sf := range plan.fields {
                idx, ok := byName[sf.argName]
                if !ok && !sf.tagged {
@@ -264,6 +266,7 @@ func (p *Plan) resolveLoneStructParam(
                        }
                }
                if !ok {
+                       unfilled = append(unfilled, fmt.Sprintf("%s (argument 
%q)", sf.goName, sf.argName))
                        continue
                }
                claimed[idx] = true
@@ -277,14 +280,25 @@ func (p *Plan) resolveLoneStructParam(
                }
        }
 
-       if len(args) == 0 && len(plan.fields) > 0 {
-               return nil, fmt.Errorf(
-                       "task function %s: no TaskFlow arg bindings arrived but 
the struct declares "+
-                               "%d bindable field(s); nothing can fill them on 
this execution path",
-                       p.fnName, len(plan.fields),
+       // Neither direction of a name mismatch is fatal, because a struct 
binds by
+       // name: an unfilled field keeps its Go zero value and an unclaimed 
argument
+       // changes nothing the handler reads. Both are logged so the mismatch 
is still
+       // visible, since the spec carries one entry per stub parameter and 
either side
+       // of it means the Go signature and the stub signature disagree.
+       //
+       // A spec that arrived empty is the same thing with every field 
unfilled, and
+       // is what an argless call looks like: build_arg_bindings sends nothing 
at all
+       // when a stub is called with no arguments.
+       if len(unfilled) > 0 {
+               logger.Warn(
+                       "Task handler declares argument(s) the Dag's call did 
not pass",
+                       "function", p.fnName,
+                       "declared_not_passed", unfilled,
+                       "passed", passedArgNames(args),
                )
        }
 
+       // Captured defaults are the normal case of an unclaimed argument and 
stay silent.
        var unclaimed []string
        for i, c := range claimed {
                if c {
@@ -295,15 +309,16 @@ func (p *Plan) resolveLoneStructParam(
                }
                name := "<nil>"
                if args[i] != nil {
-                       name = fmt.Sprintf("%q", args[i].ArgName())
+                       name = args[i].ArgName()
                }
                unclaimed = append(unclaimed, name)
        }
        if len(unclaimed) > 0 {
-               return nil, fmt.Errorf(
-                       "task function %s: %d TaskFlow call argument(s) not 
claimed by any struct "+
-                               "field: %s",
-                       p.fnName, len(unclaimed), strings.Join(unclaimed, ", "),
+               logger.Warn(
+                       "Dag's call passed argument(s) the task handler does 
not declare",
+                       "function", p.fnName,
+                       "passed_not_declared", unclaimed,
+                       "declared", declaredFieldNames(plan.fields),
                )
        }
 
@@ -337,6 +352,25 @@ func (p *Plan) resolveLoneStructParam(
        return out, nil
 }
 
+func declaredFieldNames(fields []structField) []string {
+       names := make([]string, 0, len(fields))
+       for _, sf := range fields {
+               names = append(names, sf.argName)
+       }
+       return names
+}
+
+func passedArgNames(args []Arg) []string {
+       names := make([]string, 0, len(args))
+       for _, a := range args {
+               if a == nil {
+                       continue
+               }
+               names = append(names, a.ArgName())
+       }
+       return names
+}
+
 func dropDefaultedArgs(args []Arg) []Arg {
        kept := make([]Arg, 0, len(args))
        for _, a := range args {
diff --git a/go-sdk/pkg/binding/binding_test.go 
b/go-sdk/pkg/binding/binding_test.go
index 7f803b19d6b..704ad9a90d9 100644
--- a/go-sdk/pkg/binding/binding_test.go
+++ b/go-sdk/pkg/binding/binding_test.go
@@ -18,6 +18,7 @@
 package binding
 
 import (
+       "bytes"
        "context"
        "io"
        "log/slog"
@@ -108,14 +109,31 @@ func analyze(s *BindingSuite, fn any) *Plan {
 }
 
 func (s *BindingSuite) resolve(fn any, args []Arg, client sdk.Client) 
([]reflect.Value, error) {
+       values, _, err := s.resolveWithLogs(fn, args, client)
+       return values, err
+}
+
+// resolveWithLogs is resolve with the logger's output captured, for the checks
+// that warn rather than fail.
+func (s *BindingSuite) resolveWithLogs(
+       fn any,
+       args []Arg,
+       client sdk.Client,
+) ([]reflect.Value, string, error) {
+       var logs bytes.Buffer
        plan := analyze(s, fn)
-       values, err := plan.Resolve(runtimeCtx(), slog.Default(), client, args)
+       values, err := plan.Resolve(
+               runtimeCtx(),
+               slog.New(slog.NewTextHandler(&logs, nil)),
+               client,
+               args,
+       )
        if err != nil {
-               return nil, err
+               return nil, logs.String(), err
        }
        s.Require().True(values[0].IsValid(), "parameter 0 must be bound")
        s.Require().IsType(contexttest.Context{}, values[0].Interface())
-       return values[1:], nil
+       return values[1:], logs.String(), nil
 }
 
 func (s *BindingSuite) TestAnalyzeClassification() {
@@ -865,28 +883,35 @@ type taggedRegionInput struct {
 
 func (s *BindingSuite) TestResolveTaggedStructNeverFallsBackToWholeValue() {
        fn := func(actx contexttest.Context, input taggedRegionInput) error { 
return nil }
-       _, err := s.resolve(fn, []Arg{
+       got, logs, err := s.resolveWithLogs(fn, []Arg{
                LiteralArg{Name: "region_code", Value: "eu-west-1", 
ValueSchema: argSchema("string")},
        }, &fakeXComClient{})
-       if s.Assert().Error(err) {
-               s.Contains(err.Error(), `not claimed by any struct field: 
"region_code"`)
-       }
+       s.Require().NoError(err)
+       s.Equal(
+               "",
+               got[0].Interface().(taggedRegionInput).Region,
+               "no whole-value fallback for a tagged struct",
+       )
+       // A typo'd tag shows up from both sides at once, which is what names 
it.
+       s.Contains(logs, `Region (argument \"regon_code\")`)
+       s.Contains(logs, "the task handler does not declare")
 }
 
-func (s *BindingSuite) TestResolveStructUnclaimedArgFailsLoudly() {
-       fn := func(actx contexttest.Context, input combineInput) error { return 
nil }
-       _, err := s.resolve(fn, []Arg{
+func (s *BindingSuite) TestResolveStructUnclaimedArgWarns() {
+       fn := func(actx contexttest.Context, input simpleInput) error { return 
nil }
+       got, logs, err := s.resolveWithLogs(fn, []Arg{
                LiteralArg{Name: "Name", Value: "widget", ValueSchema: 
argSchema("string")},
                LiteralArg{Name: "typo", Value: "x", ValueSchema: 
argSchema("string")},
        }, &fakeXComClient{})
-       if s.Assert().Error(err) {
-               s.Contains(err.Error(), `not claimed by any struct field: 
"typo"`)
-       }
+       s.Require().NoError(err, "an argument no field claims changes nothing 
the handler reads")
+       s.Equal("widget", got[0].Interface().(simpleInput).Name)
+       s.Contains(logs, "the task handler does not declare")
+       s.Contains(logs, "typo")
 }
 
-func (s *BindingSuite) TestResolveStructUnclaimedFromDefaultAllowed() {
-       fn := func(actx contexttest.Context, input combineInput) error { return 
nil }
-       got, err := s.resolve(fn, []Arg{
+func (s *BindingSuite) TestResolveStructUnclaimedFromDefaultStaysSilent() {
+       fn := func(actx contexttest.Context, input simpleInput) error { return 
nil }
+       got, logs, err := s.resolveWithLogs(fn, []Arg{
                LiteralArg{Name: "Name", Value: "widget", ValueSchema: 
argSchema("string")},
                LiteralArg{
                        Name:        "threshold",
@@ -896,24 +921,28 @@ func (s *BindingSuite) 
TestResolveStructUnclaimedFromDefaultAllowed() {
                },
        }, &fakeXComClient{})
        s.Require().NoError(err)
-       s.Equal("widget", got[0].Interface().(combineInput).Name)
+       s.Equal("widget", got[0].Interface().(simpleInput).Name)
+       s.NotContains(logs, "the task handler does not declare")
 }
 
-func (s *BindingSuite) TestResolveStructEmptySpecFailsLoudly() {
+func (s *BindingSuite) TestResolveStructEmptySpecWarns() {
+       // An argless call sends no spec at all, so this is the ordinary shape 
of a
+       // stub called as `my_task()`, not a sign of an Airflow that cannot 
send one.
        fn := func(actx contexttest.Context, input simpleInput) error { return 
nil }
        for name, args := range map[string][]Arg{"nil-spec": nil, "empty-spec": 
{}} {
                s.Run(name, func() {
-                       _, err := s.resolve(fn, args, &fakeXComClient{})
-                       if s.Assert().Error(err) {
-                               s.Contains(err.Error(), "no TaskFlow arg 
bindings arrived")
-                       }
+                       got, logs, err := s.resolveWithLogs(fn, args, 
&fakeXComClient{})
+                       s.Require().NoError(err)
+                       s.Equal("", got[0].Interface().(simpleInput).Name, 
"every field keeps its zero value")
+                       s.Contains(logs, "the Dag's call did not pass")
+                       s.Contains(logs, "passed=[]")
                })
        }
 }
 
 func (s *BindingSuite) TestResolveStructOnlyDefaultsZeroValues() {
        fn := func(actx contexttest.Context, input twoFieldInput) error { 
return nil }
-       got, err := s.resolve(fn, []Arg{
+       got, logs, err := s.resolveWithLogs(fn, []Arg{
                LiteralArg{
                        Name:        "threshold",
                        Value:       0.75,
@@ -925,17 +954,37 @@ func (s *BindingSuite) 
TestResolveStructOnlyDefaultsZeroValues() {
        input := got[0].Interface().(twoFieldInput)
        s.Equal("", input.Name, "no explicit entry arrived; fields keep 
kwarg-style zero values")
        s.Equal("", input.Missing)
+       s.Contains(logs, "the Dag's call did not pass")
 }
 
-func (s *BindingSuite) TestResolveStructUnmatchedFieldZeroValued() {
+func (s *BindingSuite) TestResolveStructUnmatchedFieldWarns() {
        fn := func(actx contexttest.Context, input twoFieldInput) error { 
return nil }
-       got, err := s.resolve(fn, []Arg{
+       got, logs, err := s.resolveWithLogs(fn, []Arg{
                LiteralArg{Name: "Name", Value: "widget", ValueSchema: 
argSchema("string")},
        }, &fakeXComClient{})
-       s.Require().NoError(err)
+       s.Require().NoError(err, "a struct binds by name, so an unfilled field 
is not fatal")
        input := got[0].Interface().(twoFieldInput)
        s.Equal("widget", input.Name, "the matched field binds normally")
-       s.Equal("", input.Missing, "the unmatched field is left at its Go zero 
value, not an error")
+       s.Equal("", input.Missing, "the unmatched field keeps its Go zero 
value")
+       s.Contains(logs, "the Dag's call did not pass")
+       s.Contains(logs, `Missing (argument \"missing\")`)
+       s.NotContains(logs, "the task handler does not declare", "every 
argument was claimed")
+}
+
+func (s *BindingSuite) TestResolveStructWarnsInEachDirectionAtOnce() {
+       // A call can be wrong both ways at the same time, so each direction is 
its
+       // own message rather than one line a reader has to untangle.
+       fn := func(actx contexttest.Context, input twoFieldInput) error { 
return nil }
+       got, logs, err := s.resolveWithLogs(fn, []Arg{
+               LiteralArg{Name: "Name", Value: "widget", ValueSchema: 
argSchema("string")},
+               LiteralArg{Name: "typo", Value: "x", ValueSchema: 
argSchema("string")},
+       }, &fakeXComClient{})
+       s.Require().NoError(err)
+       s.Equal("widget", got[0].Interface().(twoFieldInput).Name)
+       s.Contains(logs, "the Dag's call did not pass")
+       s.Contains(logs, `declared_not_passed="[Missing (argument 
\"missing\")]"`)
+       s.Contains(logs, "the task handler does not declare")
+       s.Contains(logs, "passed_not_declared=[typo]")
 }
 
 func (s *BindingSuite) TestResolveFlatParamsToleratesCapturedDefaults() {

Reply via email to