blob: 509e9d0e7baed82b675d7c3bb5bedc73dcebd81a [file]
// 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 (
"context"
"fmt"
"log/slog"
"runtime/debug"
"time"
"github.com/apache/airflow/go-sdk/internal/bundle"
"github.com/apache/airflow/go-sdk/pkg/binding"
"github.com/apache/airflow/go-sdk/pkg/execution/genmodels"
"github.com/apache/airflow/go-sdk/pkg/sdkcontext"
"github.com/apache/airflow/go-sdk/sdk"
)
// RunTask executes a task based on StartupDetails received from the supervisor.
//
// It looks up the task in the bundle, creates a CoordinatorClient for SDK
// calls, deletes the XComs that ti_context.xcom_keys_to_clear lists, executes
// the task, and returns the terminal body to ship as the final response frame:
// one of genmodels.SucceedTask, TaskState, or RetryTask. When RunTask cannot
// delete an XCom, or arg_bindings is invalid, the task fails without running.
//
// The supervisor owns the Execution-API state transitions, so the runtime only
// invokes the user's task function and returns its terminal response.
//
// ctx is the task's root context; Serve derives it from SIGINT/SIGTERM, so a
// cooperative task that honors ctx returns promptly on a supervisor shutdown.
func RunTask(
ctx context.Context,
b bundle.Bundle,
details *genmodels.StartupDetails,
comm *CoordinatorComm,
logger *slog.Logger,
) any {
task, exists := b.LookupTask(details.TI.DagID, details.TI.TaskID)
if !exists {
logger.Error("Task not registered",
"dag_id", details.TI.DagID,
"task_id", details.TI.TaskID,
)
return genmodels.TaskState{
State: genmodels.TaskStateStateRemoved,
EndDate: time.Now().UTC(),
}
}
client := NewCoordinatorClient(comm)
// runtimeContext carries the task instance and Dag run that binding puts
// on the task's airflow.Context. The scheduling timestamps live on the
// nested dag_run object in the supervisor's TIRunContext schema. The base
// context is a placeholder, because binding reads only the task instance
// and Dag run from this value and builds the airflow.Context around the
// live task context.
ti := sdk.TaskInstance{
DagID: details.TI.DagID,
RunID: details.TI.RunID,
TaskID: details.TI.TaskID,
MapIndex: mapIndexPtr(details.TI.MapIndex),
TryNumber: details.TI.TryNumber,
}
dagRun := details.TIContext.DagRun
runtimeContext := sdk.NewTIRunContext(
context.Background(),
ti,
sdk.DagRun{
DagID: details.TI.DagID,
RunID: details.TI.RunID,
LogicalDate: ifaceTimePtr(dagRun.LogicalDate),
DataIntervalStart: ifaceTimePtr(dagRun.DataIntervalStart),
DataIntervalEnd: ifaceTimePtr(dagRun.DataIntervalEnd),
},
)
ctx = context.WithValue(ctx, sdkcontext.SdkClientContextKey, sdk.Client(client))
ctx = context.WithValue(ctx, sdkcontext.RuntimeContextKey, runtimeContext)
ctx = bundle.WithSkipDownstreamTasks(ctx, client.skipDownstreamTasks)
// Airflow keeps the XComs that a task instance already has when it starts a new try. It lists
// their keys in xcom_keys_to_clear, and the runtime deletes them before the task runs, as the
// Python task runner does before it renders templates. The list is empty when the task resumes
// from a deferral.
for _, key := range details.TIContext.XcomKeysToClear {
logger.Debug("Clearing XCom with key", "key", key)
if err := client.deleteXCom(ctx, ti, key); err != nil {
logger.Error("Unable to clear XCom",
"dag_id", details.TI.DagID,
"task_id", details.TI.TaskID,
"key", key,
"error", err,
)
return failTask(details.TIContext.ShouldRetry, err)
}
}
args, err := convertArgBindings(details.TIContext.ArgBindings)
if err != nil {
logger.Error("Invalid arg_bindings spec from supervisor",
"dag_id", details.TI.DagID,
"task_id", details.TI.TaskID,
"error", err,
)
return failTask(details.TIContext.ShouldRetry, err)
}
return executeTask(ctx, task, args, details.TIContext.ShouldRetry, logger)
}
// failTask returns the terminal body for a task that fails before it runs: RetryTask when
// ti_context.should_retry is set, otherwise a FAILED TaskState.
func failTask(shouldRetry bool, err error) any {
if shouldRetry {
return genmodels.RetryTask{
EndDate: time.Now().UTC(),
RetryReason: err.Error(),
}
}
return genmodels.TaskState{
State: genmodels.TaskStateStateFailed,
EndDate: time.Now().UTC(),
}
}
func convertArgBindings(specsPtr *genmodels.ArgBindings) ([]binding.Arg, error) {
if specsPtr == nil || len(*specsPtr) == 0 {
return nil, nil
}
specs := *specsPtr
args := make([]binding.Arg, len(specs))
for i, raw := range specs {
m, ok := raw.(map[string]any)
if !ok {
return nil, fmt.Errorf("arg_bindings[%d]: unexpected wire shape %T", i, raw)
}
name, ok := m["name"].(string)
if !ok || name == "" {
return nil, fmt.Errorf("arg_bindings[%d]: missing or empty name", i)
}
valueSchema, err := argValueSchema(m["value_schema"])
if err != nil {
return nil, fmt.Errorf("arg_bindings[%d] (%q): %w", i, name, err)
}
switch kind, _ := m["kind"].(string); kind {
case "xcom":
taskID, ok := m["task_id"].(string)
if !ok || taskID == "" {
return nil, fmt.Errorf(
"arg_bindings[%d] (%q): missing or empty task_id for xcom kind",
i,
name,
)
}
args[i] = binding.XComArg{
Kind: kind,
Name: name,
TaskID: taskID,
ValueSchema: valueSchema,
}
case "literal":
fromDefault, err := optionalBool(m["from_default"])
if err != nil {
return nil, fmt.Errorf("arg_bindings[%d] (%q): %w", i, name, err)
}
args[i] = binding.LiteralArg{
Kind: kind,
Name: name,
Value: m["value"],
ValueSchema: valueSchema,
FromDefault: fromDefault,
}
default:
return nil, fmt.Errorf("arg_bindings[%d]: unknown kind %q", i, kind)
}
}
return args, nil
}
func argValueSchema(raw any) (*genmodels.ArgValueSchema, error) {
if raw == nil {
return nil, nil
}
m, ok := raw.(map[string]any)
if !ok {
return nil, fmt.Errorf("value_schema has unexpected wire shape %T", raw)
}
if len(m) == 0 {
return nil, nil
}
schema := make(genmodels.ArgValueSchema, len(m))
for k, v := range m {
schema[k] = v
}
return &schema, nil
}
func optionalBool(raw any) (bool, error) {
if raw == nil {
return false, nil
}
b, ok := raw.(bool)
if !ok {
return false, fmt.Errorf("from_default has unexpected wire shape %T", raw)
}
return b, nil
}
// mapIndexPtr normalizes the supervisor's map_index into the optional form
// exposed on sdk.TaskInstance: nil for an unmapped task,
// otherwise a pointer to the index. The wire field is itself optional now, so an
// unmapped task arrives as either nil (key absent) or a pointer to the -1
// sentinel; both collapse to nil here.
func mapIndexPtr(mapIndex *int) *int {
if mapIndex == nil || *mapIndex < 0 {
return nil
}
idx := *mapIndex
return &idx
}
// executeTask runs the task, handling success, failure, and panics, and returns
// the terminal body: genmodels.SucceedTask, TaskState, or RetryTask.
func executeTask(
ctx context.Context,
task bundle.Task,
args []binding.Arg,
shouldRetry bool,
logger *slog.Logger,
) (result any) {
defer func() {
if r := recover(); r != nil {
logger.Error("Recovered panic in task",
"error", r,
"stack", string(debug.Stack()),
)
if shouldRetry {
result = genmodels.RetryTask{
EndDate: time.Now().UTC(),
RetryReason: fmt.Sprintf("panic: %v", r),
}
} else {
result = genmodels.TaskState{
State: genmodels.TaskStateStateFailed,
EndDate: time.Now().UTC(),
}
}
}
}()
err := task.Execute(ctx, logger, args)
if err != nil {
logger.ErrorContext(ctx, "Task failed", "error", err)
// A task that fails when ti_context.should_retry is set is reported as
// UP_FOR_RETRY via RetryTask; otherwise it terminates as FAILED.
if shouldRetry {
return genmodels.RetryTask{
EndDate: time.Now().UTC(),
RetryReason: err.Error(),
}
}
return genmodels.TaskState{
State: genmodels.TaskStateStateFailed,
EndDate: time.Now().UTC(),
}
}
// task_outlets / outlet_events must be sent as empty lists, not omitted:
// the supervisor's SucceedTask validation rejects a null/absent value
// ("Input should be a valid list"). A task that emits no asset events
// reports empty collections rather than None.
return genmodels.SucceedTask{
EndDate: time.Now().UTC(),
TaskOutlets: &genmodels.TaskOutlets{},
OutletEvents: &genmodels.OutletEvents{},
}
}