mirror of
https://github.com/hanzoai/tasks.git
synced 2026-10-04 02:04:03 +00:00
## What changed?
Integrated chasm/lib/callback into MutableState and updated
callback-related APIs to use CHASM implementation. Added integration
tests following the scheduler pattern.
## Why?
Porting callback functionality from HSM to CHASM as part of the ongoing
HSM-to-CHASM migration effort.
## How did you test it?
- [X] built
- [X] run locally and tested manually
- [X] covered by existing tests
- [ ] added new unit test(s)
- [X] added new functional test(s)
## Potential risks
This will need to be tested more, but is not being turned on with this
PR
---
> [!NOTE]
> Integrates CHASM-based callbacks into mutable state and history APIs,
adds configs and error types, updates routing/state transitions, and
includes migration tests.
>
> - **CHASM Callback Integration**:
> - Add CHASM callback module (FX wiring, HTTP caller provider, request
routing) and metrics shared with HSM implementation.
> - Implement `ToAPICallback`, invocation flow changes, and refined
error wrapping.
> - **History/MutableState**:
> - Expose `ChasmEnabled`, `ChasmWorkflowComponent{,ReadOnly}`; use
CHASM for adding/processing completion callbacks when enabled.
> - Describe API now builds callback info from both CHASM and HSM trees.
> - Continue-as-new/retry path aggregates callbacks from both
implementations.
> - **State Machine/Executors**:
> - Switch to `queues/errors` types; move `NamespaceIDAndDestination` to
`queues/common`.
> - Adjust transitions: generate `BackoffTask` with scheduled time;
include destination on reschedule; no tasks on success/fail.
> - Simplify retry result (policy passed separately) and validation
signatures.
> - **Config**:
> - Add `EnableCHASMCallbacks` and `MaxCHASMCallbacksPerWorkflow`; wire
through history configs.
> - **Tests**:
> - Add migration and CHASM-enabled functional tests; update unit tests
to new error/types and behaviors.
>
> <sup>Written by [Cursor
Bugbot]() for commit
e3b51a931d3ea2dd7f18fb2f9c55fc880ba828d8. This will update automatically
on new commits. Configure
[here]().</sup>
---------
Co-authored-by: Roey Berman <[email protected]>
187 lines
6.3 KiB
Go
187 lines
6.3 KiB
Go
package callback
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"net/http/httptrace"
|
|
"slices"
|
|
"time"
|
|
|
|
"github.com/nexus-rpc/sdk-go/nexus"
|
|
"go.temporal.io/server/chasm"
|
|
callbackspb "go.temporal.io/server/chasm/lib/callback/gen/callbackpb/v1"
|
|
"go.temporal.io/server/common/log"
|
|
"go.temporal.io/server/common/log/tag"
|
|
"go.temporal.io/server/common/metrics"
|
|
"go.temporal.io/server/common/namespace"
|
|
commonnexus "go.temporal.io/server/common/nexus"
|
|
"go.temporal.io/server/common/nexus/nexusrpc"
|
|
queuescommon "go.temporal.io/server/service/history/queues/common"
|
|
queueserrors "go.temporal.io/server/service/history/queues/errors"
|
|
)
|
|
|
|
var retryable4xxErrorTypes = []int{
|
|
http.StatusRequestTimeout,
|
|
http.StatusTooManyRequests,
|
|
}
|
|
|
|
type nexusInvocation struct {
|
|
nexus *callbackspb.Callback_Nexus
|
|
completion nexusrpc.OperationCompletion
|
|
workflowID, runID string
|
|
attempt int32
|
|
}
|
|
|
|
func isRetryableHTTPResponse(response *http.Response) bool {
|
|
return response.StatusCode >= 500 || slices.Contains(retryable4xxErrorTypes, response.StatusCode)
|
|
}
|
|
|
|
func outcomeTag(callCtx context.Context, response *http.Response, callErr error) string {
|
|
if callErr != nil {
|
|
if callCtx.Err() != nil {
|
|
return "request-timeout"
|
|
}
|
|
return "unknown-error"
|
|
}
|
|
return fmt.Sprintf("status:%d", response.StatusCode)
|
|
}
|
|
|
|
func (n nexusInvocation) WrapError(result invocationResult, err error) error {
|
|
if retry, ok := result.(invocationResultRetry); ok {
|
|
return queueserrors.NewDestinationDownError(retry.err.Error(), err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (n nexusInvocation) Invoke(
|
|
ctx context.Context,
|
|
ns *namespace.Namespace,
|
|
e InvocationTaskExecutor,
|
|
task *callbackspb.InvocationTask,
|
|
taskAttr chasm.TaskAttributes,
|
|
) invocationResult {
|
|
if e.httpTraceProvider != nil {
|
|
traceLogger := log.With(e.logger,
|
|
tag.WorkflowNamespace(ns.Name().String()),
|
|
tag.Operation("CompleteNexusOperation"),
|
|
tag.NewStringTag("destination", taskAttr.Destination),
|
|
tag.WorkflowID(n.workflowID),
|
|
tag.WorkflowRunID(n.runID),
|
|
tag.AttemptStart(time.Now().UTC()),
|
|
tag.Attempt(n.attempt),
|
|
)
|
|
if trace := e.httpTraceProvider.NewTrace(n.attempt, traceLogger); trace != nil {
|
|
ctx = httptrace.WithClientTrace(ctx, trace)
|
|
}
|
|
}
|
|
|
|
request, err := nexusrpc.NewCompletionHTTPRequest(ctx, n.nexus.Url, n.completion)
|
|
if err != nil {
|
|
return invocationResultFail{queueserrors.NewUnprocessableTaskError(
|
|
fmt.Sprintf("failed to construct Nexus request: %v", err),
|
|
)}
|
|
}
|
|
if request.Header == nil {
|
|
request.Header = make(http.Header)
|
|
}
|
|
for k, v := range n.nexus.Header {
|
|
request.Header.Set(k, v)
|
|
}
|
|
|
|
caller := e.httpCallerProvider(queuescommon.NamespaceIDAndDestination{
|
|
NamespaceID: ns.ID().String(),
|
|
Destination: taskAttr.Destination,
|
|
})
|
|
// Make the call and record metrics.
|
|
startTime := time.Now()
|
|
response, err := caller(request)
|
|
|
|
namespaceTag := metrics.NamespaceTag(ns.Name().String())
|
|
destTag := metrics.DestinationTag(taskAttr.Destination)
|
|
statusCodeTag := metrics.OutcomeTag(outcomeTag(ctx, response, err))
|
|
e.metricsHandler.Counter(RequestCounter.Name()).Record(1, namespaceTag, destTag, statusCodeTag)
|
|
e.metricsHandler.Timer(RequestLatencyHistogram.Name()).Record(time.Since(startTime), namespaceTag, destTag, statusCodeTag)
|
|
|
|
if err != nil {
|
|
e.logger.Error("Callback request failed with error", tag.Error(err))
|
|
return invocationResultRetry{err: err}
|
|
}
|
|
|
|
if response.StatusCode >= 200 && response.StatusCode < 300 {
|
|
// Body is not read but should be discarded to keep the underlying TCP connection alive.
|
|
// Just in case something unexpected happens while discarding or closing the body,
|
|
// propagate errors to the machine.
|
|
if _, err = io.Copy(io.Discard, response.Body); err == nil {
|
|
if err = response.Body.Close(); err != nil {
|
|
e.logger.Error("Callback request failed with error", tag.Error(err))
|
|
return invocationResultRetry{err: err}
|
|
}
|
|
}
|
|
return invocationResultOK{}
|
|
}
|
|
|
|
retryable := isRetryableHTTPResponse(response)
|
|
err = readHandlerErrFromResponse(response, e.logger)
|
|
e.logger.Error("Callback request failed", tag.Error(err), tag.NewStringTag("status", response.Status), tag.NewBoolTag("retryable", retryable))
|
|
if retryable {
|
|
return invocationResultRetry{err: err}
|
|
}
|
|
return invocationResultFail{err}
|
|
}
|
|
|
|
// Reads and replaces the http response body and attempts to deserialize it into a Nexus failure. If successful,
|
|
// returns a nexus.HandlerError with the deserialized failure as the Cause. If there is an error reading the body or
|
|
// during deserialization, returns a nexus.HandlerError with a generic Cause based on response status.
|
|
// TODO: This logic is duplicated in the frontend handler for forwarded requests. Eventually it should live in the Nexus SDK.
|
|
func readHandlerErrFromResponse(response *http.Response, logger log.Logger) error {
|
|
handlerErr := &nexus.HandlerError{
|
|
Type: commonnexus.HandlerErrorTypeFromHTTPStatus(response.StatusCode),
|
|
Cause: fmt.Errorf("request failed with: %v", response.Status),
|
|
}
|
|
|
|
body, err := readAndReplaceBody(response)
|
|
if err != nil {
|
|
logger.Error("Error reading response body for non-ok callback request", tag.Error(err), tag.NewStringTag("status", response.Status))
|
|
return err
|
|
}
|
|
|
|
if !isMediaTypeJSON(response.Header.Get("Content-Type")) {
|
|
logger.Error("received invalid content-type header for non-OK HTTP response to CompleteOperation request", tag.Value(response.Header.Get("Content-Type")))
|
|
return handlerErr
|
|
}
|
|
|
|
var failure nexus.Failure
|
|
err = json.Unmarshal(body, &failure)
|
|
if err != nil {
|
|
logger.Error("failed to deserialize Nexus Failure from HTTP response to CompleteOperation request", tag.Error(err))
|
|
return handlerErr
|
|
}
|
|
|
|
handlerErr.Cause = &nexus.FailureError{Failure: failure}
|
|
return handlerErr
|
|
}
|
|
|
|
// readAndReplaceBody reads the response body in its entirety and closes it, and then replaces the original response
|
|
// body with an in-memory buffer.
|
|
// The body is replaced even when there was an error reading the entire body.
|
|
func readAndReplaceBody(response *http.Response) ([]byte, error) {
|
|
responseBody := response.Body
|
|
body, err := io.ReadAll(responseBody)
|
|
_ = responseBody.Close()
|
|
response.Body = io.NopCloser(bytes.NewReader(body))
|
|
return body, err
|
|
}
|
|
|
|
func isMediaTypeJSON(contentType string) bool {
|
|
if contentType == "" {
|
|
return false
|
|
}
|
|
mediaType, _, err := mime.ParseMediaType(contentType)
|
|
return err == nil && mediaType == "application/json"
|
|
}
|