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 53134ff6a69 Go SDK: Return XCom and task state store integers as int64 
(#75700)
53134ff6a69 is described below

commit 53134ff6a697fe50f6813e9295dc3eb3462a3615
Author: Aaron Chen <[email protected]>
AuthorDate: Tue Nov 10 21:40:17 2026 -0800

    Go SDK: Return XCom and task state store integers as int64 (#75700)
    
    * Go SDK: Return XCom and task state store integers as int64
    
    * Normalize handling of decoded integers and improve test coverage for 
number normalization
---
 .../authoring-and-scheduling/language-sdks/go.rst  |  13 +-
 go-sdk/pkg/execution/messages.go                   | 116 ++++++++++++-
 go-sdk/pkg/execution/messages_test.go              | 185 +++++++++++++++++++++
 go-sdk/pkg/execution/task_runner.go                |  55 +-----
 go-sdk/pkg/execution/task_runner_test.go           |  89 ----------
 5 files changed, 304 insertions(+), 154 deletions(-)

diff --git a/airflow-core/docs/authoring-and-scheduling/language-sdks/go.rst 
b/airflow-core/docs/authoring-and-scheduling/language-sdks/go.rst
index 03dcb72d056..66738f562d7 100644
--- a/airflow-core/docs/authoring-and-scheduling/language-sdks/go.rst
+++ b/airflow-core/docs/authoring-and-scheduling/language-sdks/go.rst
@@ -602,7 +602,7 @@ XCom type mapping
 -----------------
 
 XComs are stored as JSON, so a value read with ``GetXCom`` arrives as the 
matching Go type. The same mapping
-applies to the values in ``actx.DagRun().Conf``:
+applies to a value read with ``TaskStateStore().Get`` and to the values in 
``actx.DagRun().Conf``:
 
 .. list-table::
    :header-rows: 1
@@ -613,7 +613,7 @@ applies to the values in ``actx.DagRun().Conf``:
      - Go type
    * - ``int``
      - number (integer)
-     - an integer type (see note)
+     - ``int64`` (see note)
    * - ``float``
      - number (decimal)
      - ``float64``
@@ -635,11 +635,10 @@ applies to the values in ``actx.DagRun().Conf``:
 
 .. note::
 
-  An integer arrives as a Go integer type whose width depends on the value. In 
``actx.DagRun().Conf``, it
-  arrives as ``int64`` instead, or as ``uint64`` when it is above 
``math.MaxInt64``, including in nested maps
-  and arrays. Type-switch over the integer types you expect, or round-trip the 
value through ``json.Marshal``
-  and ``json.Unmarshal`` into a typed Go value. A task input from 
``airflow.Inputs`` or a TaskFlow argument is
-  already decoded into its parameter type.
+  An integer arrives as ``int64``, or as ``uint64`` when it is above 
``math.MaxInt64``. This also applies to
+  values in nested maps and arrays. To get a typed Go value, round-trip the 
value through ``json.Marshal`` and
+  ``json.Unmarshal``. A task input from ``airflow.Inputs`` or a TaskFlow 
argument is already decoded into its
+  parameter type.
 
 .. _go-sdk/build:
 
diff --git a/go-sdk/pkg/execution/messages.go b/go-sdk/pkg/execution/messages.go
index e494cf36edb..8fe334311d6 100644
--- a/go-sdk/pkg/execution/messages.go
+++ b/go-sdk/pkg/execution/messages.go
@@ -19,6 +19,8 @@ package execution
 
 import (
        "fmt"
+       "math"
+       "reflect"
 
        "github.com/vmihailenco/msgpack/v5"
 
@@ -67,7 +69,7 @@ func decodeIncomingBody(raw msgpack.RawMessage) (any, error) {
        switch typ {
        case genmodels.TypeStartupDetails:
                var msg genmodels.StartupDetails
-               if err := msgpack.Unmarshal(raw, &msg); err != nil {
+               if err := unmarshalNormalized(raw, &msg); err != nil {
                        return nil, fmt.Errorf("decoding StartupDetails: %w", 
err)
                }
                if err := validateStartupTI(msg.TI); err != nil {
@@ -76,13 +78,13 @@ func decodeIncomingBody(raw msgpack.RawMessage) (any, 
error) {
                return &msg, nil
        case genmodels.TypeDagFileParseRequest:
                var msg genmodels.DagFileParseRequest
-               if err := msgpack.Unmarshal(raw, &msg); err != nil {
+               if err := unmarshalNormalized(raw, &msg); err != nil {
                        return nil, fmt.Errorf("decoding DagFileParseRequest: 
%w", err)
                }
                return &msg, nil
        case genmodels.TypeErrorResponse:
                var msg genmodels.ErrorResponse
-               if err := msgpack.Unmarshal(raw, &msg); err != nil {
+               if err := unmarshalNormalized(raw, &msg); err != nil {
                        return nil, fmt.Errorf("decoding ErrorResponse: %w", 
err)
                }
                return &msg, nil
@@ -116,7 +118,113 @@ func decodeBody(raw msgpack.RawMessage, dst any) error {
        if isNilRaw(raw) {
                return fmt.Errorf("empty response body")
        }
-       return msgpack.Unmarshal(raw, dst)
+       return unmarshalNormalized(raw, dst)
+}
+
+// unmarshalNormalized decodes raw into dst, then normalizes the numbers that
+// msgpack decoded into any-typed values, wherever they sit in dst.
+func unmarshalNormalized(raw []byte, dst any) error {
+       if err := msgpack.Unmarshal(raw, dst); err != nil {
+               return err
+       }
+       normalizeDecodedNumbers(reflect.ValueOf(dst))
+       return nil
+}
+
+// normalizeDecodedNumbers applies normalizeDecodedValue, in place, to every
+// any-typed struct field, map value and slice element reachable from v.
+func normalizeDecodedNumbers(v reflect.Value) {
+       switch v.Kind() {
+       case reflect.Pointer:
+               if !v.IsNil() {
+                       normalizeDecodedNumbers(v.Elem())
+               }
+       case reflect.Interface:
+               if !v.IsNil() && v.CanSet() {
+                       
v.Set(reflect.ValueOf(normalizeDecodedValue(v.Interface())))
+               }
+       case reflect.Struct:
+               for i := range v.NumField() {
+                       if v.Type().Field(i).IsExported() {
+                               normalizeDecodedNumbers(v.Field(i))
+                       }
+               }
+       case reflect.Slice, reflect.Array:
+               if !canHoldDecodedAny(v.Type().Elem()) {
+                       return
+               }
+               for i := range v.Len() {
+                       normalizeDecodedNumbers(v.Index(i))
+               }
+       case reflect.Map:
+               if !canHoldDecodedAny(v.Type().Elem()) {
+                       return
+               }
+               elem := reflect.New(v.Type().Elem()).Elem()
+               iter := v.MapRange()
+               for iter.Next() {
+                       // A map value is not addressable, so normalize a copy 
and store it back.
+                       elem.Set(iter.Value())
+                       normalizeDecodedNumbers(elem)
+                       v.SetMapIndex(iter.Key(), elem)
+               }
+       }
+}
+
+// canHoldDecodedAny reports whether a value of type t can contain an any-typed
+// value, so that normalizeDecodedNumbers skips the elements of a []byte or a
+// map[string]string.
+func canHoldDecodedAny(t reflect.Type) bool {
+       switch t.Kind() {
+       case reflect.Interface, reflect.Pointer, reflect.Struct,
+               reflect.Slice, reflect.Array, reflect.Map:
+               return true
+       default:
+               return false
+       }
+}
+
+// normalizeDecodedValue returns value with its integers as int64, or uint64
+// above math.MaxInt64, and its floats as float64, including the values nested
+// in its maps and slices, which it updates in place. msgpack decodes a number
+// into the narrowest Go type that its wire encoding names, and the supervisor
+// packs each integer in the fewest bytes, so 7, 200 and 70000 would otherwise
+// arrive as int8, uint8 and uint32. The Java SDK decodes every integer to a
+// Long in the same way.
+func normalizeDecodedValue(value any) any {
+       switch v := value.(type) {
+       case map[string]any:
+               for key, item := range v {
+                       v[key] = normalizeDecodedValue(item)
+               }
+       case []any:
+               for i, item := range v {
+                       v[i] = normalizeDecodedValue(item)
+               }
+       case int:
+               return int64(v)
+       case uint:
+               return normalizeDecodedValue(uint64(v))
+       case int8:
+               return int64(v)
+       case int16:
+               return int64(v)
+       case int32:
+               return int64(v)
+       case uint8:
+               return int64(v)
+       case uint16:
+               return int64(v)
+       case uint32:
+               return int64(v)
+       case uint64:
+               if v <= math.MaxInt64 {
+                       return int64(v)
+               }
+       case float32:
+               return float64(v)
+       }
+       return value
 }
 
 // apiErrorFromFrame returns the supervisor error carried by a frame, or nil if
diff --git a/go-sdk/pkg/execution/messages_test.go 
b/go-sdk/pkg/execution/messages_test.go
index b94e26b14fc..0cb773bd1bd 100644
--- a/go-sdk/pkg/execution/messages_test.go
+++ b/go-sdk/pkg/execution/messages_test.go
@@ -18,8 +18,11 @@
 package execution
 
 import (
+       "bytes"
        "encoding/json"
+       "math"
        "os"
+       "reflect"
        "testing"
        "time"
 
@@ -540,6 +543,188 @@ func TestAPIErrorFromFrame(t *testing.T) {
        })
 }
 
+// marshalCompactBody encodes body with each integer in the fewest bytes, as 
the
+// supervisor's msgpack packer does.
+func marshalCompactBody(t *testing.T, body any) msgpack.RawMessage {
+       t.Helper()
+       var buf bytes.Buffer
+       enc := msgpack.NewEncoder(&buf)
+       enc.UseCompactInts(true)
+       require.NoError(t, enc.Encode(body))
+       return buf.Bytes()
+}
+
+func TestDecodeBodyNormalizesNumbers(t *testing.T) {
+       cases := []struct {
+               name  string
+               value any
+               want  any
+       }{
+               {name: "positive fixint", value: 7, want: int64(7)},
+               {name: "uint8", value: 200, want: int64(200)},
+               {name: "uint16", value: 500, want: int64(500)},
+               {name: "uint32", value: 70000, want: int64(70000)},
+               {name: "uint64 up to max int64", value: uint64(math.MaxInt64), 
want: int64(math.MaxInt64)},
+               {
+                       name:  "uint64 above max int64",
+                       value: uint64(math.MaxInt64) + 1,
+                       want:  uint64(math.MaxInt64) + 1,
+               },
+               {name: "negative fixint", value: -1, want: int64(-1)},
+               {name: "int8", value: -100, want: int64(-100)},
+               {name: "int16", value: -129, want: int64(-129)},
+               {name: "int32", value: -32769, want: int64(-32769)},
+               {name: "float32", value: float32(1.25), want: float64(1.25)},
+               {
+                       name: "nested in maps and arrays",
+                       value: map[string]any{
+                               "rows":    7,
+                               "batches": []any{200, "eu", nil, 
map[string]any{"ratio": float32(0.5)}},
+                       },
+                       want: map[string]any{
+                               "rows":    int64(7),
+                               "batches": []any{int64(200), "eu", nil, 
map[string]any{"ratio": float64(0.5)}},
+                       },
+               },
+       }
+       for _, tc := range cases {
+               t.Run(tc.name, func(t *testing.T) {
+                       raw := marshalCompactBody(t, map[string]any{
+                               "type":  "XComResult",
+                               "key":   "return_value",
+                               "value": tc.value,
+                       })
+                       var result genmodels.XComResult
+                       require.NoError(t, decodeBody(raw, &result))
+                       assert.Equal(t, tc.want, result.Value)
+               })
+       }
+}
+
+func TestDecodeIncomingBodyNormalizesNumbers(t *testing.T) {
+       cases := []struct {
+               name string
+               body map[string]any
+               got  func(msg any) any
+               want any
+       }{
+               {
+                       name: "StartupDetails dag run conf",
+                       body: map[string]any{
+                               "type": "StartupDetails",
+                               "ti": map[string]any{
+                                       "id":         
"550e8400-e29b-41d4-a716-446655440000",
+                                       "task_id":    "extract",
+                                       "dag_id":     "tutorial_dag",
+                                       "run_id":     "manual__2024-01-15",
+                                       "try_number": 1,
+                               },
+                               "bundle_info": map[string]any{"name": 
"example_dags", "version": "1.0.0"},
+                               "ti_context": map[string]any{
+                                       "dag_run": map[string]any{
+                                               "conf": map[string]any{"rows": 
200, "window": map[string]any{"days": 7}},
+                                       },
+                               },
+                       },
+                       got: func(msg any) any {
+                               return 
*msg.(*genmodels.StartupDetails).TIContext.DagRun.Conf
+                       },
+                       want: genmodels.Conf{"rows": int64(200), "window": 
map[string]any{"days": int64(7)}},
+               },
+               {
+                       name: "DagFileParseRequest callback requests",
+                       body: map[string]any{
+                               "type":        "DagFileParseRequest",
+                               "file":        "/files/dags/tutorial.go",
+                               "bundle_name": "example_dags",
+                               "bundle_path": "/files/dags",
+                               "callback_requests": []any{map[string]any{
+                                       "type": "TaskCallbackRequest",
+                                       "ti":   map[string]any{"task_id": 
"extract", "try_number": 2, "map_index": -1},
+                               }},
+                       },
+                       got: func(msg any) any {
+                               return 
msg.(*genmodels.DagFileParseRequest).CallbackRequests
+                       },
+                       want: []any{map[string]any{
+                               "type": "TaskCallbackRequest",
+                               "ti": map[string]any{
+                                       "task_id":    "extract",
+                                       "try_number": int64(2),
+                                       "map_index":  int64(-1),
+                               },
+                       }},
+               },
+               {
+                       name: "ErrorResponse detail",
+                       body: map[string]any{
+                               "type":   "ErrorResponse",
+                               "error":  "API_SERVER_ERROR",
+                               "detail": map[string]any{"status_code": 400, 
"message": "Unhandled request"},
+                       },
+                       got: func(msg any) any {
+                               return *msg.(*genmodels.ErrorResponse).Detail
+                       },
+                       want: genmodels.Detail{"status_code": int64(400), 
"message": "Unhandled request"},
+               },
+       }
+       for _, tc := range cases {
+               t.Run(tc.name, func(t *testing.T) {
+                       msg, err := decodeIncomingBody(marshalCompactBody(t, 
tc.body))
+                       require.NoError(t, err)
+                       assert.Equal(t, tc.want, tc.got(msg))
+               })
+       }
+}
+
+func TestUnmarshalNormalizedMapStructValues(t *testing.T) {
+       // No supervisor message has a map with struct values yet.
+       type entry struct {
+               Value any `msgpack:"value"`
+       }
+       var dst struct {
+               Entries map[string]entry `msgpack:"entries"`
+       }
+       raw := marshalCompactBody(t, map[string]any{
+               "entries": map[string]any{
+                       "rows":  map[string]any{"value": 200},
+                       "ratio": map[string]any{"value": float32(0.5)},
+               },
+       })
+       require.NoError(t, unmarshalNormalized(raw, &dst))
+       assert.Equal(
+               t,
+               map[string]entry{"rows": {Value: int64(200)}, "ratio": {Value: 
float64(0.5)}},
+               dst.Entries,
+       )
+}
+
+func TestCanHoldDecodedAny(t *testing.T) {
+       for _, tc := range []struct {
+               container any
+               want      bool
+       }{
+               {container: []byte(nil), want: false},
+               {container: map[string]string(nil), want: false},
+               {container: []any(nil), want: true},
+               {container: []*genmodels.XComResult(nil), want: true},
+               {container: map[string]genmodels.XComResult(nil), want: true},
+               {container: [][]any(nil), want: true},
+               {container: [][2]any(nil), want: true},
+               {container: []map[string]any(nil), want: true},
+       } {
+               elem := reflect.TypeOf(tc.container).Elem()
+               assert.Equal(t, tc.want, canHoldDecodedAny(elem), "%T", 
tc.container)
+       }
+}
+
+func TestNormalizeDecodedValueGoInts(t *testing.T) {
+       // msgpack never decodes a number into int or uint, so a decoded body 
cannot
+       // reach these cases.
+       assert.Equal(t, int64(-7), normalizeDecodedValue(int(-7)))
+       assert.Equal(t, int64(500), normalizeDecodedValue(uint(500)))
+}
+
 func TestIfaceInt(t *testing.T) {
        for _, v := range []any{
                int(42), int8(42), int16(42), int32(42), int64(42),
diff --git a/go-sdk/pkg/execution/task_runner.go 
b/go-sdk/pkg/execution/task_runner.go
index 3981e1ec799..792aff79a73 100644
--- a/go-sdk/pkg/execution/task_runner.go
+++ b/go-sdk/pkg/execution/task_runner.go
@@ -21,7 +21,6 @@ import (
        "context"
        "fmt"
        "log/slog"
-       "math"
        "runtime/debug"
        "time"
 
@@ -77,7 +76,7 @@ func RunTask(
        dagRun := details.TIContext.DagRun
        var conf map[string]any
        if dagRun.Conf != nil {
-               conf = normalizeDagRunConf(*dagRun.Conf)
+               conf = *dagRun.Conf
        }
        runtimeContext := sdk.NewTIRunContext(
                context.Background(),
@@ -133,58 +132,6 @@ func RunTask(
        return executeTask(ctx, task, args, details.TIContext.ShouldRetry, 
logger)
 }
 
-func normalizeDagRunConf(conf map[string]any) map[string]any {
-       if conf == nil {
-               return nil
-       }
-       normalized := make(map[string]any, len(conf))
-       for key, value := range conf {
-               normalized[key] = normalizeDagRunConfValue(value)
-       }
-       return normalized
-}
-
-func normalizeDagRunConfValue(value any) any {
-       switch v := value.(type) {
-       case map[string]any:
-               return normalizeDagRunConf(v)
-       case []any:
-               if v == nil {
-                       return v
-               }
-               normalized := make([]any, len(v))
-               for i, item := range v {
-                       normalized[i] = normalizeDagRunConfValue(item)
-               }
-               return normalized
-       case int:
-               return int64(v)
-       case int8:
-               return int64(v)
-       case int16:
-               return int64(v)
-       case int32:
-               return int64(v)
-       case uint:
-               return normalizeDagRunConfValue(uint64(v))
-       case uint8:
-               return int64(v)
-       case uint16:
-               return int64(v)
-       case uint32:
-               return int64(v)
-       case uint64:
-               if v <= math.MaxInt64 {
-                       return int64(v)
-               }
-               return v
-       case float32:
-               return float64(v)
-       default:
-               return value
-       }
-}
-
 func taskInstanceOf(details *genmodels.StartupDetails) sdk.TaskInstance {
        return sdk.TaskInstance{
                DagID:     details.TI.DagID,
diff --git a/go-sdk/pkg/execution/task_runner_test.go 
b/go-sdk/pkg/execution/task_runner_test.go
deleted file mode 100644
index ecc06a30d75..00000000000
--- a/go-sdk/pkg/execution/task_runner_test.go
+++ /dev/null
@@ -1,89 +0,0 @@
-// 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 execution
-
-import (
-       "math"
-       "testing"
-
-       "github.com/stretchr/testify/assert"
-       "github.com/stretchr/testify/require"
-)
-
-func TestNormalizeDagRunConfNumbers(t *testing.T) {
-       cases := []struct {
-               name  string
-               value any
-               want  any
-       }{
-               {name: "int", value: int(7), want: int64(7)},
-               {name: "int8", value: int8(-1), want: int64(-1)},
-               {name: "int16", value: int16(-129), want: int64(-129)},
-               {name: "int32", value: int32(-32769), want: int64(-32769)},
-               {name: "uint", value: uint(500), want: int64(500)},
-               {name: "uint8", value: uint8(200), want: int64(200)},
-               {name: "uint16", value: uint16(500), want: int64(500)},
-               {name: "uint32", value: uint32(70000), want: int64(70000)},
-               {
-                       name:  "large exact integer",
-                       value: uint64(9007199254740993),
-                       want:  int64(9007199254740993),
-               },
-               {name: "max int64", value: uint64(math.MaxInt64), want: 
int64(math.MaxInt64)},
-               {
-                       name:  "above max int64",
-                       value: uint64(math.MaxInt64) + 1,
-                       want:  uint64(math.MaxInt64) + 1,
-               },
-               {name: "float32", value: float32(1.25), want: float64(1.25)},
-       }
-       for _, tc := range cases {
-               t.Run(tc.name, func(t *testing.T) {
-                       original := map[string]any{"value": tc.value}
-                       got := normalizeDagRunConf(original)
-                       assert.Equal(t, tc.want, got["value"])
-                       assert.Equal(t, tc.value, original["value"])
-               })
-       }
-}
-
-func TestNormalizeDagRunConfCopiesContainers(t *testing.T) {
-       original := map[string]any{
-               "label":       "original",
-               "nested":      map[string]any{"bucket": "reports"},
-               "list":        []any{map[string]any{"region": "us"}, 
[]any{"eu"}},
-               "nil_map":     map[string]any(nil),
-               "empty_map":   map[string]any{},
-               "nil_slice":   []any(nil),
-               "empty_slice": []any{},
-               "flag":        true,
-               "null":        nil,
-       }
-       normalized := normalizeDagRunConf(original)
-       require.Equal(t, original, normalized)
-
-       normalized["label"] = "changed"
-       normalized["nested"].(map[string]any)["bucket"] = "changed"
-       normalized["list"].([]any)[0].(map[string]any)["region"] = "changed"
-       normalized["list"].([]any)[1].([]any)[0] = "changed"
-
-       assert.Equal(t, "original", original["label"])
-       assert.Equal(t, "reports", 
original["nested"].(map[string]any)["bucket"])
-       assert.Equal(t, "us", 
original["list"].([]any)[0].(map[string]any)["region"])
-       assert.Equal(t, "eu", original["list"].([]any)[1].([]any)[0])
-}

Reply via email to