Files
tasks/chasm/lib/callback/nexus_invocation.go
b701b9bae2 Add CHASM callback into mutable state (#8582)
## 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]>
2025-11-24 12:31:44 -07:00

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"
}