diff --git a/.github/workflows/sdk-go.yml b/.github/workflows/sdk-go.yml index 8ad20c6d68..6fe8125d93 100644 --- a/.github/workflows/sdk-go.yml +++ b/.github/workflows/sdk-go.yml @@ -21,13 +21,19 @@ defaults: jobs: compile: runs-on: ubuntu-latest + # Verify the SDK against both supported Go versions: 1.26 exercises the reflection + # (fn any) constructors, 1.27 exercises the generic-method constructors. + strategy: + fail-fast: false + matrix: + go-version: ["1.26", "1.27"] steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: - go-version: "1.26" + go-version: "${{ matrix.go-version }}" - name: Compile Go SDK examples run: | # Find all directories with go.mod files diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5d54bcc6dd..c46dd911e4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -34,7 +34,7 @@ repos: - id: check-yaml exclude: (^examples/|^cmd/hatchet-cli/cli/templates/) - repo: https://github.com/golangci/golangci-lint - rev: v2.12.2 + rev: v2.13.2 hooks: - id: golangci-lint args: ["--config=.golangci.yml", "--allow-parallel-runners"] diff --git a/Taskfile.yaml b/Taskfile.yaml index b135ef4110..4ae79293e8 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -56,7 +56,10 @@ tasks: SERVER_INTERNAL_CLIENT_BASE_INHERIT_BASE=false EOF fmt-go: - cmd: gofmt -s -w . + # *_go127.go hold Go 1.27 generic methods, which the go1.26 gofmt parser rejects. + # gofmt ignores build tags, so those files are excluded here and formatted with a + # go1.27+ toolchain instead. -exec ... + batches args to avoid ARG_MAX limits. + cmd: find . -type f -name '*.go' -not -name '*_go127.go' -exec gofmt -s -w {} + fmt-app: dir: frontend/app cmd: pnpm run prettier:fix && pnpm run prettier:check @@ -233,7 +236,10 @@ tasks: generate-sdk-docs-go: dir: sdks/go cmds: - - go run ./docs/generator + # Pin to Go 1.27+ so the generator's parser can read the generic-method (go1.27) + # files and document both the go1.27 and pre-go1.27 signatures. An older toolchain + # can't parse the generic files and would emit only the reflection signatures. + - GOTOOLCHAIN=go1.27.0 go run ./docs/generator generate-sdk-docs-ruby: dir: sdks/ruby cmds: diff --git a/examples/python/bug_tests/subscribe_to_stream_dag/worker.py b/examples/python/bug_tests/subscribe_to_stream_dag/worker.py new file mode 100644 index 0000000000..6f64a4c512 --- /dev/null +++ b/examples/python/bug_tests/subscribe_to_stream_dag/worker.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import asyncio + +from hatchet_sdk import Context, EmptyModel, Hatchet + +hatchet = Hatchet() + +STREAM_CHUNKS = [f"c{i:02d}|" for i in range(20)] +PRE_STREAM_SLEEP_S = 2.0 +INTER_CHUNK_S = 0.05 + +long_stream = hatchet.workflow(name="subscribe-stream-long") + + +@long_stream.task() +async def long_streamer(_: EmptyModel, ctx: Context) -> None: + await asyncio.sleep(PRE_STREAM_SLEEP_S) + for chunk in STREAM_CHUNKS: + await ctx.aio_put_stream(chunk) + await asyncio.sleep(INTER_CHUNK_S) + + +# An on_failure handler makes every run a DAG. DAG QUEUED->RUNNING used to be +# published as workflow-run-finished, which hung up stream subscribers as soon +# as the run started. +dag_stream = hatchet.workflow(name="subscribe-stream-dag") + + +@dag_stream.task() +async def dag_streamer(_: EmptyModel, ctx: Context) -> None: + await asyncio.sleep(PRE_STREAM_SLEEP_S) + for chunk in STREAM_CHUNKS: + await ctx.aio_put_stream(chunk) + + +@dag_stream.on_failure_task() +async def on_failure(_: EmptyModel, _ctx: Context) -> None: + pass + + +def main() -> None: + worker = hatchet.worker( + "subscribe-stream-dag-worker", + slots=8, + workflows=[long_stream, dag_stream], + ) + worker.start() + + +if __name__ == "__main__": + main() diff --git a/examples/typescript/subscribe_to_stream/worker.ts b/examples/typescript/subscribe_to_stream/worker.ts new file mode 100644 index 0000000000..89d2832a46 --- /dev/null +++ b/examples/typescript/subscribe_to_stream/worker.ts @@ -0,0 +1,15 @@ +import { hatchet } from '../hatchet-client'; +import { dagStream, longStream } from './workflow'; + +async function main() { + const worker = await hatchet.worker('subscribe-stream-worker', { + workflows: [longStream, dagStream], + slots: 8, + }); + + await worker.start(); +} + +if (require.main === module) { + main(); +} diff --git a/examples/typescript/subscribe_to_stream/workflow.ts b/examples/typescript/subscribe_to_stream/workflow.ts new file mode 100644 index 0000000000..e5f4b1aeff --- /dev/null +++ b/examples/typescript/subscribe_to_stream/workflow.ts @@ -0,0 +1,43 @@ +import sleep from '../../../util/sleep'; +import { hatchet } from '../hatchet-client'; + +export const STREAM_CHUNKS = Array.from( + { length: 20 }, + (_, i) => `c${String(i).padStart(2, '0')}|` +); + +export const PRE_STREAM_SLEEP_MS = 2000; +export const INTER_CHUNK_MS = 50; + +export const longStream = hatchet.task({ + name: 'subscribe-stream-long', + fn: async (_, ctx) => { + await sleep(PRE_STREAM_SLEEP_MS); + for (const chunk of STREAM_CHUNKS) { + await ctx.putStream(chunk); + await sleep(INTER_CHUNK_MS); + } + }, +}); + +// An onFailure handler makes the run a DAG. DAG QUEUED->RUNNING used to be +// published as workflow-run-finished, which hung up stream subscribers as +// soon as the run started. +export const dagStream = hatchet.workflow({ + name: 'subscribe-stream-dag', +}); + +dagStream.task({ + name: 'subscribe-stream-dag', + fn: async (_, ctx) => { + await sleep(PRE_STREAM_SLEEP_MS); + for (const chunk of STREAM_CHUNKS) { + await ctx.putStream(chunk); + } + }, +}); + +dagStream.onFailure({ + name: 'subscribe-stream-dag-on-failure', + fn: async () => {}, +}); diff --git a/frontend/docs/content/docs/reference/go/client.mdx b/frontend/docs/content/docs/reference/go/client.mdx index 0bd7143cf1..658e2fea2e 100644 --- a/frontend/docs/content/docs/reference/go/client.mdx +++ b/frontend/docs/content/docs/reference/go/client.mdx @@ -197,29 +197,25 @@ Returns: #### `NewStandaloneDurableTask` -NewStandaloneDurableTask creates a standalone durable task that can be triggered independently. This is a specialized workflow containing only one durable task, making it easier to create simple single-task workflows with durable functionality. - -The function parameter must have the signature: +NewStandaloneDurableTask creates a standalone durable task that can be triggered independently. Like NewStandaloneTask, the input and output types are type parameters checked at compile time and invoked without reflection. ```go -func(ctx hatchet.DurableContext, input any) (any, error) +func(ctx hatchet.DurableContext, input In) (Out, error) ``` -Function signatures are validated at runtime using reflection. - Options can be any combination of WorkflowOption and TaskOption. ```go -func (c *Client) NewStandaloneDurableTask(name string, fn any, options ...StandaloneTaskOption) *StandaloneTask +func (c *Client) NewStandaloneDurableTask[I, O any](name string, fn func(ctx DurableContext, input I) (O, error), options ...StandaloneTaskOption) *StandaloneTask ``` Parameters: -| Name | Type | -| --------- | ------------------------- | -| `name` | `string` | -| `fn` | `any` | -| `options` | `...StandaloneTaskOption` | +| Name | Type | +| --------- | ---------------------------------------------- | +| `name` | `string` | +| `fn` | `func(ctx DurableContext, input I) (O, error)` | +| `options` | `...StandaloneTaskOption` | Returns: @@ -234,24 +230,24 @@ NewStandaloneTask creates a standalone task that can be triggered independently. The function parameter must have the signature: ```go -func(ctx hatchet.Context, input any) (any, error) +func(ctx hatchet.Context, input In) (Out, error) ``` -Function signatures are validated at runtime using reflection. +The input and output types are type parameters, checked at compile time and invoked without reflection. They are inferred from the function, so existing call sites are unchanged. Options can be any combination of WorkflowOption and TaskOption. ```go -func (c *Client) NewStandaloneTask(name string, fn any, options ...StandaloneTaskOption) *StandaloneTask +func (c *Client) NewStandaloneTask[I, O any](name string, fn func(ctx Context, input I) (O, error), options ...StandaloneTaskOption) *StandaloneTask ``` Parameters: -| Name | Type | -| --------- | ------------------------- | -| `name` | `string` | -| `fn` | `any` | -| `options` | `...StandaloneTaskOption` | +| Name | Type | +| --------- | --------------------------------------- | +| `name` | `string` | +| `fn` | `func(ctx Context, input I) (O, error)` | +| `options` | `...StandaloneTaskOption` | Returns: diff --git a/frontend/docs/content/docs/reference/go/runnables.mdx b/frontend/docs/content/docs/reference/go/runnables.mdx index 6b157f4e7d..a580c5adb9 100644 --- a/frontend/docs/content/docs/reference/go/runnables.mdx +++ b/frontend/docs/content/docs/reference/go/runnables.mdx @@ -17,16 +17,16 @@ Workflow defines a Hatchet workflow, which can then declare tasks and be run, sc Methods: -| Name | Description | -| ---------------- | ------------------------------------------------------------------------------------------------- | -| `GetName` | GetName returns the resolved workflow name (including namespace if applicable). | -| `NewBatchTask` | NewBatchTask transforms a function into a Hatchet batch task that runs as part of a workflow. | -| `NewDurableTask` | NewDurableTask transforms a function into a durable Hatchet task that runs as part of a workflow. | -| `NewTask` | NewTask transforms a function into a Hatchet task that runs as part of a workflow. | -| `OnFailure` | OnFailure sets a failure handler for the workflow. | -| `Run` | Run executes the workflow with the provided input and waits for completion. | -| `RunMany` | RunMany executes multiple workflow instances with different inputs. | -| `RunNoWait` | RunNoWait executes the workflow with the provided input without waiting for completion. | +| Name | Description | +| ---------------- | ------------------------------------------------------------------------------------------------------- | +| `GetName` | GetName returns the resolved workflow name (including namespace if applicable). | +| `NewBatchTask` | NewBatchTask transforms a function into a Hatchet batch task that runs as part of a workflow. | +| `NewDurableTask` | NewDurableTask transforms a typed function into a durable Hatchet task that runs as part of a workflow. | +| `NewTask` | NewTask transforms a typed function into a Hatchet task that runs as part of a workflow. | +| `OnFailure` | OnFailure sets a typed failure handler for the workflow. | +| `Run` | Run executes the workflow with the provided input and waits for completion. | +| `RunMany` | RunMany executes multiple workflow instances with different inputs. | +| `RunNoWait` | RunNoWait executes the workflow with the provided input without waiting for completion. | ### Functions @@ -85,27 +85,23 @@ Returns: #### `NewDurableTask` -NewDurableTask transforms a function into a durable Hatchet task that runs as part of a workflow. - -The function parameter must have the signature: +NewDurableTask transforms a typed function into a durable Hatchet task that runs as part of a workflow. Like NewTask, the input and output types are type parameters checked at compile time. On Go 1.26 and earlier, fn is typed any and validated at runtime instead. ```go -func(ctx hatchet.DurableContext, input any) (any, error) +func(ctx hatchet.DurableContext, input In) (Out, error) ``` -Function signatures are validated at runtime using reflection. - ```go -func (w *Workflow) NewDurableTask(name string, fn any, options ...TaskOption) *Task +func (w *Workflow) NewDurableTask[I, O any](name string, fn func(ctx DurableContext, input I) (O, error), options ...TaskOption) *Task ``` Parameters: -| Name | Type | -| --------- | --------------- | -| `name` | `string` | -| `fn` | `any` | -| `options` | `...TaskOption` | +| Name | Type | +| --------- | ---------------------------------------------- | +| `name` | `string` | +| `fn` | `func(ctx DurableContext, input I) (O, error)` | +| `options` | `...TaskOption` | Returns: @@ -115,27 +111,25 @@ Returns: #### `NewTask` -NewTask transforms a function into a Hatchet task that runs as part of a workflow. +NewTask transforms a typed function into a Hatchet task that runs as part of a workflow. -The function parameter must have the signature: +The input and output types are type parameters inferred from the function, so the signature is checked at compile time and invoked directly. On Go 1.26 and earlier, fn is typed any and validated at runtime instead. ```go -func(ctx hatchet.Context, input any) (any, error) +func(ctx hatchet.Context, input In) (Out, error) ``` -Function signatures are validated at runtime using reflection. - ```go -func (w *Workflow) NewTask(name string, fn any, options ...TaskOption) *Task +func (w *Workflow) NewTask[I, O any](name string, fn func(ctx Context, input I) (O, error), options ...TaskOption) *Task ``` Parameters: -| Name | Type | -| --------- | --------------- | -| `name` | `string` | -| `fn` | `any` | -| `options` | `...TaskOption` | +| Name | Type | +| --------- | --------------------------------------- | +| `name` | `string` | +| `fn` | `func(ctx Context, input I) (O, error)` | +| `options` | `...TaskOption` | Returns: @@ -145,17 +139,21 @@ Returns: #### `OnFailure` -OnFailure sets a failure handler for the workflow. The handler will be called when any task in the workflow fails. +OnFailure sets a typed failure handler for the workflow. It runs when any task in the workflow fails and receives the same input the workflow was triggered with. On Go 1.26 and earlier, fn is typed any and validated at runtime instead. + +```go +func(ctx hatchet.Context, input In) (Out, error) +``` ```go -func (w *Workflow) OnFailure(fn any) +func (w *Workflow) OnFailure[I, O any](fn func(ctx Context, input I) (O, error)) ``` Parameters: -| Name | Type | -| ---- | ----- | -| `fn` | `any` | +| Name | Type | +| ---- | --------------------------------------- | +| `fn` | `func(ctx Context, input I) (O, error)` | #### `Run` @@ -234,7 +232,7 @@ Methods: | Name | Description | | ----------- | ---------------------------------------------------------------------------------------------- | | `GetName` | GetName returns the name of the standalone task. | -| `OnFailure` | OnFailure sets a failure handler for the standalone task. | +| `OnFailure` | OnFailure sets a typed failure handler for the standalone task. | | `Run` | Run executes the standalone task with the provided input and waits for completion. | | `RunMany` | RunMany executes multiple standalone task instances with different inputs. | | `RunNoWait` | RunNoWait executes the standalone task with the provided input without waiting for completion. | @@ -257,17 +255,17 @@ Returns: #### `OnFailure` -OnFailure sets a failure handler for the standalone task. The handler will be called when the standalone task fails. +OnFailure sets a typed failure handler for the standalone task. It runs when the task fails and receives the same input the task was triggered with. The input and output types are type parameters, inferred from the handler and invoked without reflection. ```go -func (st *StandaloneTask) OnFailure(fn any) +func (st *StandaloneTask) OnFailure[I, O any](fn func(ctx Context, input I) (O, error)) ``` Parameters: -| Name | Type | -| ---- | ----- | -| `fn` | `any` | +| Name | Type | +| ---- | --------------------------------------- | +| `fn` | `func(ctx Context, input I) (O, error)` | #### `Run` diff --git a/internal/services/shared/proto/v1/dispatcher.pb.go b/internal/services/shared/proto/v1/dispatcher.pb.go index 7c36079fea..175e3f70bf 100644 --- a/internal/services/shared/proto/v1/dispatcher.pb.go +++ b/internal/services/shared/proto/v1/dispatcher.pb.go @@ -1140,7 +1140,6 @@ type DurableTaskRequest struct { unknownFields protoimpl.UnknownFields // Types that are assignable to Message: - // // *DurableTaskRequest_RegisterWorker // *DurableTaskRequest_Memo // *DurableTaskRequest_TriggerRuns @@ -1354,7 +1353,6 @@ type DurableTaskResponse struct { unknownFields protoimpl.UnknownFields // Types that are assignable to Message: - // // *DurableTaskResponse_RegisterWorker // *DurableTaskResponse_MemoAck // *DurableTaskResponse_TriggerRunsAck diff --git a/internal/services/shared/proto/v1/trigger.pb.go b/internal/services/shared/proto/v1/trigger.pb.go index 73fdab6836..548a7c7844 100644 --- a/internal/services/shared/proto/v1/trigger.pb.go +++ b/internal/services/shared/proto/v1/trigger.pb.go @@ -86,17 +86,17 @@ type DesiredWorkerLabels struct { // value of the affinity StrValue *string `protobuf:"bytes,1,opt,name=str_value,json=strValue,proto3,oneof" json:"str_value,omitempty"` IntValue *int32 `protobuf:"varint,2,opt,name=int_value,json=intValue,proto3,oneof" json:"int_value,omitempty"` - // * + //* // (optional) Specifies whether the affinity setting is required. // If required, the worker will not accept actions that do not have a truthy affinity setting. // // Defaults to false. Required *bool `protobuf:"varint,3,opt,name=required,proto3,oneof" json:"required,omitempty"` - // * + //* // (optional) Specifies the comparator for the affinity setting. // If not set, the default is EQUAL. Comparator *WorkerLabelComparator `protobuf:"varint,4,opt,name=comparator,proto3,enum=v1.WorkerLabelComparator,oneof" json:"comparator,omitempty"` - // * + //* // (optional) Specifies the weight of the affinity setting. // If not set, the default is 100. Weight *int32 `protobuf:"varint,5,opt,name=weight,proto3,oneof" json:"weight,omitempty"` diff --git a/pkg/repository/mq.go b/pkg/repository/mq.go index 861d4bd43b..e6424cded3 100644 --- a/pkg/repository/mq.go +++ b/pkg/repository/mq.go @@ -50,12 +50,12 @@ func newMessageQueueRepository(shared *sharedRepository) (*messageQueueRepositor m := newMultiplexedListener(shared.l, shared.pool) return &messageQueueRepository{ - sharedRepository: shared, - m: m, - }, func() error { - m.cancel() - return nil - } + sharedRepository: shared, + m: m, + }, func() error { + m.cancel() + return nil + } } // NewMessageQueueRepositoryWithPool constructs a standalone MessageQueueRepository diff --git a/pkg/worker/context.go b/pkg/worker/context.go index 522bd5ec93..4c2a4b3399 100644 --- a/pkg/worker/context.go +++ b/pkg/worker/context.go @@ -245,7 +245,7 @@ type JobRunLookupData struct { // Deprecated: StepRunData is an internal type used by the new Go SDK. // Use the new Go SDK at github.com/hatchet-dev/hatchet/sdks/go instead of using this directly. Migration guide: https://docs.hatchet.run/home/migration-guide-go type StepRunData struct { - Input map[string]interface{} `json:"input"` + Input json.RawMessage `json:"input"` TriggeredBy TriggeredBy `json:"triggered_by"` Parents map[string]StepData `json:"parents"` Triggers map[string]map[string]interface{} `json:"triggers,omitempty"` @@ -415,7 +415,10 @@ func (h *hatchetContext) TriggeredByEvent() bool { // Deprecated: WorkflowInput is an internal method used by the new Go SDK. // Use the new Go SDK at github.com/hatchet-dev/hatchet/sdks/go instead of using this directly. Migration guide: https://docs.hatchet.run/home/migration-guide-go func (h *hatchetContext) WorkflowInput(target interface{}) error { - return toTarget(h.stepData.Input, target) + if len(h.stepData.Input) == 0 { + return nil + } + return json.Unmarshal(h.stepData.Input, target) } // BatchInputInto decodes the buffered items of a batch task's START_BATCH action into @@ -877,16 +880,8 @@ func (h *hatchetContext) populateStepDataForGroupKeyRun() error { return nil } - inputData := map[string]interface{}{} - - err := json.Unmarshal(h.a.ActionPayload, &inputData) - - if err != nil { - return err - } - h.stepData = &StepRunData{ - Input: inputData, + Input: h.a.ActionPayload, } return nil diff --git a/sdks/go/client.go b/sdks/go/client.go index db05bb9172..41719a19d2 100644 --- a/sdks/go/client.go +++ b/sdks/go/client.go @@ -650,23 +650,10 @@ func promoteTaskTriggers(taskOptions []TaskOption, workflowOptions []WorkflowOpt return workflowOptions } -// NewStandaloneTask creates a standalone task that can be triggered independently. -// This is a specialized workflow containing only one task, making it easier to create -// simple single-task workflows without the workflow boilerplate. -// -// The function parameter must have the signature: -// -// func(ctx hatchet.Context, input any) (any, error) -// -// Function signatures are validated at runtime using reflection. -// -// Options can be any combination of WorkflowOption and TaskOption. -func (c *Client) NewStandaloneTask(name string, fn any, options ...StandaloneTaskOption) *StandaloneTask { - if name == "" { - panic("standalone task name cannot be empty") - } - - // Separate workflow and task options +// splitStandaloneOptions separates the mixed workflow/task options passed to the standalone +// constructors and promotes task-level triggers (cron/events) to the workflow. Shared by +// both the Go 1.27+ generic constructors and the reflection-based fallback. +func splitStandaloneOptions(name string, options []StandaloneTaskOption) ([]WorkflowOption, []TaskOption) { var workflowOptions []WorkflowOption var taskOptions []TaskOption @@ -677,22 +664,11 @@ func (c *Client) NewStandaloneTask(name string, fn any, options ...StandaloneTas case TaskOption: taskOptions = append(taskOptions, o) default: - panic("invalid option type for standalone task - must be WorkflowOption or TaskOption") + panic("invalid option type for standalone task '" + name + "' - must be WorkflowOption or TaskOption") } } - workflowOptions = promoteTaskTriggers(taskOptions, workflowOptions) - - // Create a workflow with the same name as the task - workflow := c.NewWorkflow(name, workflowOptions...) - - // Create the single task within the workflow - task := workflow.NewTask(name, fn, taskOptions...) - - return &StandaloneTask{ - workflow: workflow, - task: task, - } + return promoteTaskTriggers(taskOptions, workflowOptions), taskOptions } // NewStandaloneBatchTask creates a standalone batch task that can be triggered independently. @@ -746,51 +722,6 @@ func (c *Client) NewStandaloneBatchTask(name string, fn any, batch BatchConfig, } } -// NewStandaloneDurableTask creates a standalone durable task that can be triggered independently. -// This is a specialized workflow containing only one durable task, making it easier to create -// simple single-task workflows with durable functionality. -// -// The function parameter must have the signature: -// -// func(ctx hatchet.DurableContext, input any) (any, error) -// -// Function signatures are validated at runtime using reflection. -// -// Options can be any combination of WorkflowOption and TaskOption. -func (c *Client) NewStandaloneDurableTask(name string, fn any, options ...StandaloneTaskOption) *StandaloneTask { - if name == "" { - panic("standalone durable task name cannot be empty") - } - - // Separate workflow and task options - var workflowOptions []WorkflowOption - var taskOptions []TaskOption - - for _, opt := range options { - switch o := opt.(type) { - case WorkflowOption: - workflowOptions = append(workflowOptions, o) - case TaskOption: - taskOptions = append(taskOptions, o) - default: - panic("invalid option type for standalone durable task - must be WorkflowOption or TaskOption") - } - } - - workflowOptions = promoteTaskTriggers(taskOptions, workflowOptions) - - // Create a workflow with the same name as the task - workflow := c.NewWorkflow(name, workflowOptions...) - - // Create the single durable task within the workflow - task := workflow.NewDurableTask(name, fn, taskOptions...) - - return &StandaloneTask{ - workflow: workflow, - task: task, - } -} - // Run executes the standalone task with the provided input and waits for completion. func (st *StandaloneTask) Run(ctx context.Context, input any, opts ...RunOptFunc) (*TaskResult, error) { workflowRunRef, err := st.workflow.Run(ctx, input, opts...) @@ -825,12 +756,6 @@ func (st *StandaloneTask) RunMany(ctx context.Context, inputs []RunManyOpt) ([]W return workflowRefs, nil } -// OnFailure sets a failure handler for the standalone task. -// The handler will be called when the standalone task fails. -func (st *StandaloneTask) OnFailure(fn any) { - st.workflow.OnFailure(fn) -} - // WorkflowRunRef is a type that represents a reference to a workflow run. type WorkflowRunRef struct { RunId string diff --git a/sdks/go/docs/generator/main.go b/sdks/go/docs/generator/main.go index fe77605000..a78f7d62ef 100644 --- a/sdks/go/docs/generator/main.go +++ b/sdks/go/docs/generator/main.go @@ -17,6 +17,7 @@ import ( "encoding/json" "fmt" "go/ast" + "go/build" "go/doc" "go/parser" "go/printer" @@ -298,12 +299,20 @@ func loadPackage(dir, importPath string) (*pkgDocs, error) { if err != nil { return nil, err } + // build.Default honors //go:build tags, so only the files the running toolchain compiles + // are documented. Run the generator on the newest supported Go. var fileNames []string for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") || strings.HasSuffix(e.Name(), "_test.go") { continue } - fileNames = append(fileNames, e.Name()) + matched, err := build.Default.MatchFile(dir, e.Name()) + if err != nil { + return nil, err + } + if matched { + fileNames = append(fileNames, e.Name()) + } } sort.Strings(fileNames) @@ -428,6 +437,7 @@ func signature(recv string, name string, ft *ast.FuncType) string { b.WriteString("(" + recv + ") ") } b.WriteString(name) + b.WriteString(renderTypeParams(ft.TypeParams)) b.WriteString(renderFieldList(ft.Params, true)) results := fieldListParams(ft.Results) switch len(results) { @@ -479,6 +489,27 @@ func recvString(fn *doc.Func) string { return typ } +// renderTypeParams renders a generic type-parameter list, e.g. "[I, O any]". +func renderTypeParams(fl *ast.FieldList) string { + if fl == nil || len(fl.List) == 0 { + return "" + } + var parts []string + for _, f := range fl.List { + typ := exprString(f.Type) + if len(f.Names) == 0 { + parts = append(parts, typ) + continue + } + names := make([]string, len(f.Names)) + for i, n := range f.Names { + names[i] = n.Name + } + parts = append(parts, strings.Join(names, ", ")+" "+typ) + } + return "[" + strings.Join(parts, ", ") + "]" +} + // ---------- markdown table helpers ---------- func escapeCell(s string) string { diff --git a/sdks/go/internal/declaration.go b/sdks/go/internal/declaration.go index 64557a485e..62c4ea51b8 100644 --- a/sdks/go/internal/declaration.go +++ b/sdks/go/internal/declaration.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "fmt" - "reflect" "strings" "time" @@ -139,13 +138,6 @@ type workflowDeclarationImpl[I any, O any] struct { tasks []*task.TaskDeclaration[I] durableTasks []*task.DurableTaskDeclaration[I] - // Store task functions with their specific output types - taskFuncs map[string]interface{} - durableTaskFuncs map[string]interface{} - - // Map to store task output setters - outputSetters map[string]func(*O, interface{}) - DefaultPriority *int32 DefaultFilters []types.DefaultFilter Idempotency *create.IdempotencyConfig @@ -194,17 +186,14 @@ func NewWorkflowDeclaration[I any, O any](opts create.WorkflowCreateOpts[I], v0 CronInput: opts.CronInput, Concurrency: opts.Concurrency, // OnFailureTask: opts.OnFailureTask, // TODO: add this back in - StickyStrategy: opts.StickyStrategy, - TaskDefaults: opts.TaskDefaults, - outputKey: opts.OutputKey, - tasks: []*task.TaskDeclaration[I]{}, - taskFuncs: make(map[string]interface{}), - durableTasks: []*task.DurableTaskDeclaration[I]{}, - durableTaskFuncs: make(map[string]interface{}), - outputSetters: make(map[string]func(*O, interface{})), - DefaultPriority: opts.DefaultPriority, - DefaultFilters: opts.DefaultFilters, - Idempotency: opts.Idempotency, + StickyStrategy: opts.StickyStrategy, + TaskDefaults: opts.TaskDefaults, + outputKey: opts.OutputKey, + tasks: []*task.TaskDeclaration[I]{}, + durableTasks: []*task.DurableTaskDeclaration[I]{}, + DefaultPriority: opts.DefaultPriority, + DefaultFilters: opts.DefaultFilters, + Idempotency: opts.Idempotency, } if opts.Version != "" { @@ -225,57 +214,11 @@ func (w *workflowDeclarationImpl[I, O]) Name() string { // Task registers a standard (non-durable) task with the workflow func (w *workflowDeclarationImpl[I, O]) Task(opts create.WorkflowTask[I, O], fn func(ctx worker.HatchetContext, input I) (interface{}, error)) *task.TaskDeclaration[I] { - name := opts.Name - - // Use reflection to validate the function type - fnType := reflect.TypeOf(fn) - if fnType.Kind() != reflect.Func || - fnType.NumIn() != 2 || - fnType.NumOut() != 2 || - !fnType.Out(1).Implements(reflect.TypeOf((*error)(nil)).Elem()) { - panic("Invalid function type for task " + name + ": must be func(I, worker.HatchetContext) (*T, error)") - } - - // Create a setter function that can set this specific output type to the corresponding field in O - w.outputSetters[name] = func(result *O, output interface{}) { - resultValue := reflect.ValueOf(result).Elem() - field := resultValue.FieldByName(name) - - // If the field isn't found by name, try to find it by JSON tag - resultType := resultValue.Type() - for i := 0; i < resultType.NumField(); i++ { - fieldType := resultType.Field(i) - jsonTag := fieldType.Tag.Get("json") - // Extract the name part from the json tag (before any comma) - if commaIdx := strings.Index(jsonTag, ","); commaIdx > 0 { - jsonTag = jsonTag[:commaIdx] - } - if jsonTag == name || strings.EqualFold(fieldType.Name, name) { - field = resultValue.Field(i) - break - } - } - - if field.IsValid() && field.CanSet() { - outputValue := reflect.ValueOf(output).Elem() - field.Set(outputValue) - } - } - - // Create a generic task function that wraps the specific one genericFn := func(ctx worker.HatchetContext, input I) (*any, error) { - // Use reflection to call the specific function - fnValue := reflect.ValueOf(fn) - inputs := []reflect.Value{reflect.ValueOf(ctx), reflect.ValueOf(input)} - results := fnValue.Call(inputs) - - // Handle errors - if !results[1].IsNil() { - return nil, results[1].Interface().(error) + output, err := fn(ctx, input) + if err != nil { + return nil, err } - - // Return the output as any - output := results[0].Interface() return &output, nil } @@ -329,59 +272,17 @@ func (w *workflowDeclarationImpl[I, O]) Task(opts create.WorkflowTask[I, O], fn } w.tasks = append(w.tasks, taskDecl) - w.taskFuncs[name] = fn return taskDecl } // BatchTask registers a batch task with the workflow func (w *workflowDeclarationImpl[I, O]) BatchTask(opts create.WorkflowTask[I, O], batch *types.BatchConfig, fn func(ctx worker.HatchetContext, input I) (interface{}, error)) *task.TaskDeclaration[I] { - name := opts.Name - - // Use reflection to validate the function type - fnType := reflect.TypeOf(fn) - if fnType.Kind() != reflect.Func || - fnType.NumIn() != 2 || - fnType.NumOut() != 2 || - !fnType.Out(1).Implements(reflect.TypeOf((*error)(nil)).Elem()) { - panic("Invalid function type for batch task " + name + ": must be func(worker.HatchetContext, I) (*T, error)") - } - - // Create a setter function that can set this specific output type to the corresponding field in O - w.outputSetters[name] = func(result *O, output interface{}) { - resultValue := reflect.ValueOf(result).Elem() - field := resultValue.FieldByName(name) - - resultType := resultValue.Type() - for i := 0; i < resultType.NumField(); i++ { - fieldType := resultType.Field(i) - jsonTag := fieldType.Tag.Get("json") - if commaIdx := strings.Index(jsonTag, ","); commaIdx > 0 { - jsonTag = jsonTag[:commaIdx] - } - if jsonTag == name || strings.EqualFold(fieldType.Name, name) { - field = resultValue.Field(i) - break - } - } - - if field.IsValid() && field.CanSet() { - outputValue := reflect.ValueOf(output).Elem() - field.Set(outputValue) - } - } - - // Create a generic task function that wraps the specific one genericFn := func(ctx worker.HatchetContext, input I) (*any, error) { - fnValue := reflect.ValueOf(fn) - inputs := []reflect.Value{reflect.ValueOf(ctx), reflect.ValueOf(input)} - results := fnValue.Call(inputs) - - if !results[1].IsNil() { - return nil, results[1].Interface().(error) + output, err := fn(ctx, input) + if err != nil { + return nil, err } - - output := results[0].Interface() return &output, nil } @@ -437,49 +338,17 @@ func (w *workflowDeclarationImpl[I, O]) BatchTask(opts create.WorkflowTask[I, O] } w.tasks = append(w.tasks, taskDecl) - w.taskFuncs[name] = fn return taskDecl } // DurableTask registers a durable task with the workflow func (w *workflowDeclarationImpl[I, O]) DurableTask(opts create.WorkflowTask[I, O], fn func(ctx worker.DurableHatchetContext, input I) (interface{}, error)) *task.DurableTaskDeclaration[I] { - name := opts.Name - - // Use reflection to validate the function type - fnType := reflect.TypeOf(fn) - if fnType.Kind() != reflect.Func || - fnType.NumIn() != 2 || - fnType.NumOut() != 2 || - !fnType.Out(1).Implements(reflect.TypeOf((*error)(nil)).Elem()) { - panic("Invalid function type for durable task " + name + ": must be func(I, worker.DurableHatchetContext) (*T, error)") - } - - // Create a setter function that can set this specific output type to the corresponding field in O - w.outputSetters[name] = func(result *O, output interface{}) { - resultValue := reflect.ValueOf(result).Elem() - field := resultValue.FieldByName(name) - - if field.IsValid() && field.CanSet() { - outputValue := reflect.ValueOf(output).Elem() - field.Set(outputValue) - } - } - - // Create a generic task function that wraps the specific one genericFn := func(ctx worker.DurableHatchetContext, input I) (*any, error) { - // Use reflection to call the specific function - fnValue := reflect.ValueOf(fn) - inputs := []reflect.Value{reflect.ValueOf(ctx), reflect.ValueOf(input)} - results := fnValue.Call(inputs) - - // Handle errors - if !results[1].IsNil() { - return nil, results[1].Interface().(error) + output, err := fn(ctx, input) + if err != nil { + return nil, err } - - // Return the output as any - output := results[0].Interface() return &output, nil } @@ -543,36 +412,17 @@ func (w *workflowDeclarationImpl[I, O]) DurableTask(opts create.WorkflowTask[I, } w.durableTasks = append(w.durableTasks, taskDecl) - w.durableTaskFuncs[name] = fn return taskDecl } // OnFailureTask registers a task that will be executed if the workflow fails. func (w *workflowDeclarationImpl[I, O]) OnFailure(opts create.WorkflowOnFailureTask[I, O], fn func(ctx worker.HatchetContext, input I) (interface{}, error)) *task.OnFailureTaskDeclaration[I] { - // Use reflection to validate the function type - fnType := reflect.TypeOf(fn) - if fnType.Kind() != reflect.Func || - fnType.NumIn() != 2 || - fnType.NumOut() != 2 || - !fnType.Out(1).Implements(reflect.TypeOf((*error)(nil)).Elem()) { - panic("Invalid function type for on failure task: must be func(I, worker.HatchetContext) (*T, error)") - } - - // Create a generic task function that wraps the specific one genericFn := func(ctx worker.HatchetContext, input I) (*any, error) { - // Use reflection to call the specific function - fnValue := reflect.ValueOf(fn) - inputs := []reflect.Value{reflect.ValueOf(ctx), reflect.ValueOf(input)} - results := fnValue.Call(inputs) - - // Handle errors - if !results[1].IsNil() { - return nil, results[1].Interface().(error) + output, err := fn(ctx, input) + if err != nil { + return nil, err } - - // Return the output as any - output := results[0].Interface() return &output, nil } @@ -801,9 +651,8 @@ func (w *workflowDeclarationImpl[I, O]) Dump() (*contracts.CreateWorkflowVersion // Create named function objects for regular tasks regularNamedFns := make([]NamedFunction, len(w.tasks)) for i, task := range w.tasks { - taskName := task.Name - originalFn := w.taskFuncs[taskName] isBatch := task.Batch != nil + fn := task.Fn.(func(ctx worker.HatchetContext, input I) (*any, error)) regularNamedFns[i] = NamedFunction{ ActionID: taskOpts[i].Action, @@ -818,24 +667,11 @@ func (w *workflowDeclarationImpl[I, O]) Dump() (*contracts.CreateWorkflowVersion return nil, err } - // Call the original function using reflection - fnValue := reflect.ValueOf(originalFn) - inputVal := reflect.ValueOf(input) - if !inputVal.IsValid() { - // input is a nil interface (e.g. I = any with null input), - // use a zero value of the function's expected parameter type - inputVal = reflect.Zero(fnValue.Type().In(1)) - } - inputs := []reflect.Value{reflect.ValueOf(ctx), inputVal} - results := fnValue.Call(inputs) - - // Handle errors - if !results[1].IsNil() { - return nil, results[1].Interface().(error) + output, err := fn(ctx, input) + if err != nil { + return nil, err } - - // Return the output - return results[0].Interface(), nil + return *output, nil }, } } @@ -843,8 +679,7 @@ func (w *workflowDeclarationImpl[I, O]) Dump() (*contracts.CreateWorkflowVersion // Create named function objects for durable tasks durableNamedFns := make([]NamedFunction, len(w.durableTasks)) for i, task := range w.durableTasks { - taskName := task.Name - originalFn := w.durableTaskFuncs[taskName] + fn := task.Fn.(func(ctx worker.DurableHatchetContext, input I) (*any, error)) durableNamedFns[i] = NamedFunction{ ActionID: durableOpts[i].Action, @@ -856,31 +691,20 @@ func (w *workflowDeclarationImpl[I, O]) Dump() (*contracts.CreateWorkflowVersion return nil, err } - // Create a DurableHatchetContext from the HatchetContext durableCtx := worker.NewDurableHatchetContext(ctx) - // Call the original function using reflection - fnValue := reflect.ValueOf(originalFn) - inputVal := reflect.ValueOf(input) - if !inputVal.IsValid() { - inputVal = reflect.Zero(fnValue.Type().In(1)) - } - inputs := []reflect.Value{reflect.ValueOf(durableCtx), inputVal} - results := fnValue.Call(inputs) - - // Handle errors - if !results[1].IsNil() { - return nil, results[1].Interface().(error) + output, err := fn(durableCtx, input) + if err != nil { + return nil, err } - - // Return the output - return results[0].Interface(), nil + return *output, nil }, } } var onFailureFn WrappedTaskFn if w.OnFailureTask != nil { + fn := w.OnFailureTask.Fn.(func(ctx worker.HatchetContext, input I) (*any, error)) onFailureFn = func(ctx worker.HatchetContext) (interface{}, error) { var input I err := ctx.WorkflowInput(&input) @@ -888,20 +712,11 @@ func (w *workflowDeclarationImpl[I, O]) Dump() (*contracts.CreateWorkflowVersion return nil, err } - // Call the function using reflection - fnValue := reflect.ValueOf(w.OnFailureTask.Fn) - inputs := []reflect.Value{reflect.ValueOf(ctx), reflect.ValueOf(input)} - results := fnValue.Call(inputs) - - // Handle errors - if !results[1].IsNil() { - return nil, results[1].Interface().(error) + output, err := fn(ctx, input) + if err != nil { + return nil, err } - - // Get the result - result := results[0].Interface() - - return result, nil + return *output, nil } } diff --git a/sdks/go/internal/declaration_test.go b/sdks/go/internal/declaration_test.go index 60343f17bf..c6def38e56 100644 --- a/sdks/go/internal/declaration_test.go +++ b/sdks/go/internal/declaration_test.go @@ -67,13 +67,10 @@ func TestWorkflowDump_DefaultFilters(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { wf := &workflowDeclarationImpl[any, any]{ - name: "test-workflow", - tasks: []*task.TaskDeclaration[any]{}, - durableTasks: []*task.DurableTaskDeclaration[any]{}, - taskFuncs: make(map[string]any), - durableTaskFuncs: make(map[string]any), - outputSetters: make(map[string]func(*any, any)), - DefaultFilters: tc.filters, + name: "test-workflow", + tasks: []*task.TaskDeclaration[any]{}, + durableTasks: []*task.DurableTaskDeclaration[any]{}, + DefaultFilters: tc.filters, } req, _, _, _ := wf.Dump() diff --git a/sdks/go/standalone_constructors_go127.go b/sdks/go/standalone_constructors_go127.go new file mode 100644 index 0000000000..7df2162cb9 --- /dev/null +++ b/sdks/go/standalone_constructors_go127.go @@ -0,0 +1,64 @@ +//go:build go1.27 + +// Generic, reflection-free standalone task constructors, using Go 1.27 generic methods. + +package hatchet + +// NewStandaloneTask creates a standalone task that can be triggered independently. +// This is a specialized workflow containing only one task, making it easier to create +// simple single-task workflows without the workflow boilerplate. +// +// The function parameter must have the signature: +// +// func(ctx hatchet.Context, input In) (Out, error) +// +// The input and output types are type parameters, checked at compile time and invoked +// without reflection. They are inferred from the function, so existing call sites are +// unchanged. +// +// Options can be any combination of WorkflowOption and TaskOption. +func (c *Client) NewStandaloneTask[I, O any](name string, fn func(ctx Context, input I) (O, error), options ...StandaloneTaskOption) *StandaloneTask { + if name == "" { + panic("standalone task name cannot be empty") + } + + workflowOptions, taskOptions := splitStandaloneOptions(name, options) + + workflow := c.NewWorkflow(name, workflowOptions...) + task := workflow.NewTask(name, fn, taskOptions...) + + return &StandaloneTask{ + workflow: workflow, + task: task, + } +} + +// NewStandaloneDurableTask creates a standalone durable task that can be triggered +// independently. Like NewStandaloneTask, the input and output types are type parameters +// checked at compile time and invoked without reflection. +// +// func(ctx hatchet.DurableContext, input In) (Out, error) +// +// Options can be any combination of WorkflowOption and TaskOption. +func (c *Client) NewStandaloneDurableTask[I, O any](name string, fn func(ctx DurableContext, input I) (O, error), options ...StandaloneTaskOption) *StandaloneTask { + if name == "" { + panic("standalone durable task name cannot be empty") + } + + workflowOptions, taskOptions := splitStandaloneOptions(name, options) + + workflow := c.NewWorkflow(name, workflowOptions...) + task := workflow.NewDurableTask(name, fn, taskOptions...) + + return &StandaloneTask{ + workflow: workflow, + task: task, + } +} + +// OnFailure sets a typed failure handler for the standalone task. It runs when the task +// fails and receives the same input the task was triggered with. The input and output +// types are type parameters, inferred from the handler and invoked without reflection. +func (st *StandaloneTask) OnFailure[I, O any](fn func(ctx Context, input I) (O, error)) { + st.workflow.OnFailure(fn) +} diff --git a/sdks/go/standalone_constructors_pre_go127.go b/sdks/go/standalone_constructors_pre_go127.go new file mode 100644 index 0000000000..2329252871 --- /dev/null +++ b/sdks/go/standalone_constructors_pre_go127.go @@ -0,0 +1,64 @@ +//go:build !go1.27 + +// Reflection-based standalone task constructors for Go toolchains without generic methods (< 1.27). + +package hatchet + +// NewStandaloneTask creates a standalone task that can be triggered independently. +// This is a specialized workflow containing only one task, making it easier to create +// simple single-task workflows without the workflow boilerplate. +// +// The function parameter must have the signature: +// +// func(ctx hatchet.Context, input any) (any, error) +// +// Function signatures are validated at runtime using reflection. +// +// Options can be any combination of WorkflowOption and TaskOption. +func (c *Client) NewStandaloneTask(name string, fn any, options ...StandaloneTaskOption) *StandaloneTask { + if name == "" { + panic("standalone task name cannot be empty") + } + + workflowOptions, taskOptions := splitStandaloneOptions(name, options) + + workflow := c.NewWorkflow(name, workflowOptions...) + task := workflow.NewTask(name, fn, taskOptions...) + + return &StandaloneTask{ + workflow: workflow, + task: task, + } +} + +// NewStandaloneDurableTask creates a standalone durable task that can be triggered +// independently. This is a specialized workflow containing only one durable task. +// +// The function parameter must have the signature: +// +// func(ctx hatchet.DurableContext, input any) (any, error) +// +// Function signatures are validated at runtime using reflection. +// +// Options can be any combination of WorkflowOption and TaskOption. +func (c *Client) NewStandaloneDurableTask(name string, fn any, options ...StandaloneTaskOption) *StandaloneTask { + if name == "" { + panic("standalone durable task name cannot be empty") + } + + workflowOptions, taskOptions := splitStandaloneOptions(name, options) + + workflow := c.NewWorkflow(name, workflowOptions...) + task := workflow.NewDurableTask(name, fn, taskOptions...) + + return &StandaloneTask{ + workflow: workflow, + task: task, + } +} + +// OnFailure sets a failure handler for the standalone task. +// The handler will be called when the standalone task fails. +func (st *StandaloneTask) OnFailure(fn any) { + st.workflow.OnFailure(fn) +} diff --git a/sdks/go/task_constructors_go127.go b/sdks/go/task_constructors_go127.go new file mode 100644 index 0000000000..6a86fb9737 --- /dev/null +++ b/sdks/go/task_constructors_go127.go @@ -0,0 +1,79 @@ +//go:build go1.27 + +package hatchet + +import ( + "github.com/hatchet-dev/hatchet/pkg/client/create" +) + +func wrapTyped[C interface{ WorkflowInput(any) error }, I, O any](fn func(C, I) (O, error)) func(C, any) (any, error) { + return func(ctx C, _ any) (any, error) { + var in I + if err := ctx.WorkflowInput(&in); err != nil { + return nil, err + } + return fn(ctx, in) + } +} + +// NewTask transforms a typed function into a Hatchet task that runs as part of a workflow. +// +// The input and output types are type parameters inferred from the function, so the +// signature is checked at compile time and invoked directly. On Go 1.26 and earlier, +// fn is typed any and validated at runtime instead. +// +// func(ctx hatchet.Context, input In) (Out, error) +func (w *Workflow) NewTask[I, O any](name string, fn func(ctx Context, input I) (O, error), options ...TaskOption) *Task { + if name == "" { + panic("task name cannot be empty") + } + + if fn == nil { + panic("task '" + name + "' has a nil input function") + } + + config := &taskConfig{} + + for _, opt := range options { + opt(config) + } + + w.declaration.Task(config.createOpts(name), wrapTyped(fn)) + + return &Task{name: name} +} + +// NewDurableTask transforms a typed function into a durable Hatchet task that runs as part +// of a workflow. Like NewTask, the input and output types are type parameters checked at +// compile time. On Go 1.26 and earlier, fn is typed any and validated at runtime instead. +// +// func(ctx hatchet.DurableContext, input In) (Out, error) +func (w *Workflow) NewDurableTask[I, O any](name string, fn func(ctx DurableContext, input I) (O, error), options ...TaskOption) *Task { + if name == "" { + panic("task name cannot be empty") + } + + if fn == nil { + panic("task '" + name + "' has a nil input function") + } + + config := &taskConfig{} + + for _, opt := range options { + opt(config) + } + + durableDecl := w.declaration.DurableTask(config.createOpts(name), wrapTyped(fn)) + durableDecl.EvictionPolicy = config.evictionOpts() + + return &Task{name: name} +} + +// OnFailure sets a typed failure handler for the workflow. It runs when any task in the +// workflow fails and receives the same input the workflow was triggered with. On Go 1.26 +// and earlier, fn is typed any and validated at runtime instead. +// +// func(ctx hatchet.Context, input In) (Out, error) +func (w *Workflow) OnFailure[I, O any](fn func(ctx Context, input I) (O, error)) { + w.declaration.OnFailure(create.WorkflowOnFailureTask[any, any]{}, wrapTyped(fn)) +} diff --git a/sdks/go/task_constructors_pre_go127.go b/sdks/go/task_constructors_pre_go127.go new file mode 100644 index 0000000000..6ffa506722 --- /dev/null +++ b/sdks/go/task_constructors_pre_go127.go @@ -0,0 +1,132 @@ +//go:build !go1.27 + +package hatchet + +import ( + "reflect" + "slices" + + "github.com/hatchet-dev/hatchet/pkg/client/create" + "github.com/hatchet-dev/hatchet/pkg/worker" +) + +func validateTaskFn(fnType reflect.Type, what string) { + if fnType.Kind() != reflect.Func { + panic(what + " must be a function") + } + if fnType.NumIn() != 2 { + panic(what + " must have exactly 2 parameters: (ctx hatchet.Context, input T)") + } + if fnType.NumOut() != 2 { + panic(what + " must return exactly 2 values: (output T, err error)") + } + if !fnType.Out(1).Implements(reflect.TypeOf((*error)(nil)).Elem()) { + panic(what + ": second return value must be error") + } +} + +func callReflected(fnValue reflect.Value, ctx Context, input any) (any, error) { + fnType := fnValue.Type() + contextArg := reflect.ValueOf(ctx) + durableContextType := reflect.TypeOf((*worker.DurableHatchetContext)(nil)).Elem() + if fnType.In(0).Implements(durableContextType) || fnType.In(0) == durableContextType { + contextArg = reflect.ValueOf(worker.NewDurableHatchetContext(ctx)) + } + + results := fnValue.Call([]reflect.Value{contextArg, convertInputToType(input, fnType.In(1))}) + + var err error + if !results[1].IsNil() { + err = results[1].Interface().(error) + } + return results[0].Interface(), err +} + +// NewTask transforms a function into a Hatchet task that runs as part of a workflow. +// +// The function parameter must have the signature: +// +// func(ctx hatchet.Context, input any) (any, error) +// +// Function signatures are validated at runtime using reflection. +func (w *Workflow) NewTask(name string, fn any, options ...TaskOption) *Task { + if name == "" { + panic("task name cannot be empty") + } + + if fn == nil { + panic("task '" + name + "' has a nil input function") + } + + config := &taskConfig{} + + for _, opt := range options { + opt(config) + } + + fnValue := reflect.ValueOf(fn) + fnType := fnValue.Type() + validateTaskFn(fnType, "fn") + + contextType := reflect.TypeOf((*Context)(nil)).Elem() + durableContextType := reflect.TypeOf((*worker.DurableHatchetContext)(nil)).Elem() + + if config.isDurable { + if !fnType.In(0).Implements(durableContextType) && fnType.In(0) != durableContextType { + panic("first parameter for durable task must be hatchet.DurableContext") + } + } else { + if !fnType.In(0).Implements(contextType) && fnType.In(0) != contextType { + panic("first parameter must be hatchet.Context") + } + } + + wrapper := func(ctx Context, input any) (any, error) { + return callReflected(fnValue, ctx, input) + } + + if config.isDurable { + durableWrapper := func(ctx worker.DurableHatchetContext, input any) (any, error) { + return wrapper(ctx, input) + } + durableDecl := w.declaration.DurableTask(config.createOpts(name), durableWrapper) + durableDecl.EvictionPolicy = config.evictionOpts() + } else { + w.declaration.Task(config.createOpts(name), wrapper) + } + + return &Task{name: name} +} + +// NewDurableTask transforms a function into a durable Hatchet task that runs as part of a workflow. +// +// The function parameter must have the signature: +// +// func(ctx hatchet.DurableContext, input any) (any, error) +// +// Function signatures are validated at runtime using reflection. +func (w *Workflow) NewDurableTask(name string, fn any, options ...TaskOption) *Task { + return w.NewTask(name, fn, append(slices.Clone(options), withDurable())...) +} + +// OnFailure sets a failure handler for the workflow. +// The handler will be called when any task in the workflow fails. +// +// Function signatures are validated at runtime using reflection. +func (w *Workflow) OnFailure(fn any) { + fnValue := reflect.ValueOf(fn) + fnType := fnValue.Type() + validateTaskFn(fnType, "onFailure function") + + contextType := reflect.TypeOf((*Context)(nil)).Elem() + if !fnType.In(0).Implements(contextType) && fnType.In(0) != contextType { + panic("first parameter must be Context") + } + + w.declaration.OnFailure( + create.WorkflowOnFailureTask[any, any]{}, + func(ctx Context, input any) (any, error) { + return callReflected(fnValue, ctx, input) + }, + ) +} diff --git a/sdks/go/worker.go b/sdks/go/worker.go index 11869a4fad..08ba791042 100644 --- a/sdks/go/worker.go +++ b/sdks/go/worker.go @@ -23,7 +23,6 @@ type workerConfig struct { type WorkflowBase interface { GetName() string - OnFailure(fn any) // Internal use only. Will be removed in the future. Dump() (*v1.CreateWorkflowVersionRequest, []internal.NamedFunction, []internal.NamedFunction, internal.WrappedTaskFn) diff --git a/sdks/go/workflow.go b/sdks/go/workflow.go index 49ff9daef8..396116a00f 100644 --- a/sdks/go/workflow.go +++ b/sdks/go/workflow.go @@ -345,6 +345,34 @@ type taskConfig struct { slotCost *int32 } +func (c *taskConfig) createOpts(name string) create.WorkflowTask[any, any] { + return create.WorkflowTask[any, any]{ + Name: name, + Retries: c.retries, + RetryBackoffFactor: c.retryBackoffFactor, + RetryMaxBackoffSeconds: c.retryMaxBackoffSeconds, + ExecutionTimeout: c.executionTimeout, + ScheduleTimeout: c.scheduleTimeout, + Concurrency: c.concurrency, + RateLimits: c.rateLimits, + Parents: c.parents, + WaitFor: c.waitFor, + SkipIf: c.skipIf, + SlotCost: c.slotCost, + } +} + +func (c *taskConfig) evictionOpts() *internal.EvictionPolicyOpts { + if c.evictionPolicy == nil { + return nil + } + return &internal.EvictionPolicyOpts{ + TTL: c.evictionPolicy.TTL, + AllowCapacityEviction: c.evictionPolicy.AllowCapacityEviction, + Priority: c.evictionPolicy.Priority, + } +} + // WithRetries sets the number of retry attempts for failed tasks. func WithRetries(retries int) TaskOption { return func(config *taskConfig) { @@ -477,127 +505,6 @@ func (t *Task) GetName() string { return t.name } -// NewTask transforms a function into a Hatchet task that runs as part of a workflow. -// -// The function parameter must have the signature: -// -// func(ctx hatchet.Context, input any) (any, error) -// -// Function signatures are validated at runtime using reflection. -func (w *Workflow) NewTask(name string, fn any, options ...TaskOption) *Task { - if name == "" { - panic("task name cannot be empty") - } - - if fn == nil { - panic("task '" + name + "' has a nil input function") - } - - config := &taskConfig{} - - for _, opt := range options { - opt(config) - } - - fnValue := reflect.ValueOf(fn) - fnType := fnValue.Type() - - if fnType.Kind() != reflect.Func { - panic("fn must be a function") - } - - if fnType.NumIn() != 2 { - panic("fn must have exactly 2 parameters: (ctx hatchet.Context, input T)") - } - - if fnType.NumOut() != 2 { - panic("fn must return exactly 2 values: (output T, err error)") - } - - contextType := reflect.TypeOf((*Context)(nil)).Elem() - durableContextType := reflect.TypeOf((*worker.DurableHatchetContext)(nil)).Elem() - - if config.isDurable { - if !fnType.In(0).Implements(durableContextType) && fnType.In(0) != durableContextType { - panic("first parameter for durable task must be hatchet.DurableContext") - } - } else { - if !fnType.In(0).Implements(contextType) && fnType.In(0) != contextType { - panic("first parameter must be hatchet.Context") - } - } - - errorType := reflect.TypeOf((*error)(nil)).Elem() - if !fnType.Out(1).Implements(errorType) { - panic("second return value must be error") - } - - wrapper := func(ctx Context, input any) (any, error) { - // Convert the input to the expected type - expectedInputType := fnType.In(1) - convertedInput := convertInputToType(input, expectedInputType) - - // For durable tasks, we need to pass the context as the expected type - var contextArg reflect.Value - durableContextType := reflect.TypeOf((*worker.DurableHatchetContext)(nil)).Elem() - if fnType.In(0).Implements(durableContextType) || fnType.In(0) == durableContextType { - // For durable tasks, convert the context to DurableHatchetContext - durableCtx := worker.NewDurableHatchetContext(ctx) - contextArg = reflect.ValueOf(durableCtx) - } else { - contextArg = reflect.ValueOf(ctx) - } - - args := []reflect.Value{ - contextArg, - convertedInput, - } - - results := fnValue.Call(args) - - output := results[0].Interface() - var err error - if !results[1].IsNil() { - err = results[1].Interface().(error) - } - - return output, err - } - - taskOpts := create.WorkflowTask[any, any]{ - Name: name, - Retries: config.retries, - RetryBackoffFactor: config.retryBackoffFactor, - RetryMaxBackoffSeconds: config.retryMaxBackoffSeconds, - ExecutionTimeout: config.executionTimeout, - ScheduleTimeout: config.scheduleTimeout, - Concurrency: config.concurrency, - RateLimits: config.rateLimits, - Parents: config.parents, - WaitFor: config.waitFor, - SkipIf: config.skipIf, - SlotCost: config.slotCost, - } - - if config.isDurable { - durableWrapper := func(ctx worker.DurableHatchetContext, input any) (any, error) { - return wrapper(ctx, input) - } - durableDecl := w.declaration.DurableTask(taskOpts, durableWrapper) - if config.evictionPolicy != nil { - durableDecl.EvictionPolicy = &internal.EvictionPolicyOpts{ - TTL: config.evictionPolicy.TTL, - AllowCapacityEviction: config.evictionPolicy.AllowCapacityEviction, - Priority: config.evictionPolicy.Priority, - } - } - } else { - w.declaration.Task(taskOpts, wrapper) - } - - return &Task{name: name} -} - // NewBatchTask transforms a function into a Hatchet batch task that runs as part of a // workflow. Batch tasks buffer concurrent runs until Hatchet flushes the batch (size // reached or flush interval), then invoke the handler once with all buffered inputs keyed @@ -757,89 +664,11 @@ func (w *Workflow) NewBatchTask(name string, fn any, batch BatchConfig, options return &Task{name: name} } -// NewDurableTask transforms a function into a durable Hatchet task that runs as part of a workflow. -// -// The function parameter must have the signature: -// -// func(ctx hatchet.DurableContext, input any) (any, error) -// -// Function signatures are validated at runtime using reflection. -func (w *Workflow) NewDurableTask(name string, fn any, options ...TaskOption) *Task { - durableOptions := make([]TaskOption, len(options), len(options)+1) - copy(durableOptions, options) - durableOptions = append(durableOptions, withDurable()) - return w.NewTask(name, fn, durableOptions...) -} - // Dump implements the WorkflowBase interface for internal use. func (w *Workflow) Dump() (*v1.CreateWorkflowVersionRequest, []internal.NamedFunction, []internal.NamedFunction, internal.WrappedTaskFn) { return w.declaration.Dump() } -// OnFailure sets a failure handler for the workflow. -// The handler will be called when any task in the workflow fails. -func (w *Workflow) OnFailure(fn any) { - fnValue := reflect.ValueOf(fn) - fnType := fnValue.Type() - - if fnType.Kind() != reflect.Func { - panic("onFailure function must be a function") - } - if fnType.NumIn() != 2 { - panic("onFailure function must have exactly 2 parameters: (ctx Context, input T)") - } - if fnType.NumOut() != 2 { - panic("onFailure function must return exactly 2 values: (output T, error)") - } - - contextType := reflect.TypeOf((*Context)(nil)).Elem() - if !fnType.In(0).Implements(contextType) && fnType.In(0) != contextType { - panic("first parameter must be Context") - } - - errorType := reflect.TypeOf((*error)(nil)).Elem() - if !fnType.Out(1).Implements(errorType) { - panic("second return value must be error") - } - - wrapper := func(ctx Context, input any) (any, error) { - // Convert the input to the expected type - expectedInputType := fnType.In(1) - convertedInput := convertInputToType(input, expectedInputType) - - // For durable tasks, we need to pass the context as the expected type - var contextArg reflect.Value - durableContextType := reflect.TypeOf((*worker.DurableHatchetContext)(nil)).Elem() - if fnType.In(0).Implements(durableContextType) || fnType.In(0) == durableContextType { - // For durable tasks, convert the context to DurableHatchetContext - durableCtx := worker.NewDurableHatchetContext(ctx) - contextArg = reflect.ValueOf(durableCtx) - } else { - contextArg = reflect.ValueOf(ctx) - } - - args := []reflect.Value{ - contextArg, - convertedInput, - } - - results := fnValue.Call(args) - - output := results[0].Interface() - var err error - if !results[1].IsNil() { - err = results[1].Interface().(error) - } - - return output, err - } - - w.declaration.OnFailure( - create.WorkflowOnFailureTask[any, any]{}, - wrapper, - ) -} - // Workflow execution methods // Run executes the workflow with the provided input and waits for completion.