mirror of
https://github.com/hanzoai/tasks.git
synced 2026-10-04 02:04:03 +00:00
## What changed? Added support for Nexus workflow update completion callbacks via CHASM. This allows a Nexus caller to be notified when a workflow update completes by attaching completion callbacks to the update request. ## Why? Nexus operations that target workflow updates need a way to receive completion notifications. Without this, a Nexus caller that sends an update has no async mechanism to learn when the update finishes. Completion callbacks enable the same async notification pattern that already exists for workflow-level Nexus operations. ## How did you test it? - [ ] built - [x] run locally and tested manually - [ ] covered by existing tests - [x] added new unit test(s) - [x] added new functional test(s) ## Potential risks Touches speculative workflow updates, they are always hard to reason about. Tried to compensate with lots of test coverage. Note: Needs this API PR https://github.com/temporalio/api/pull/742/changes --- > [!NOTE] > **High Risk** > Touches workflow update state machine and mutable state event handling to persist/trigger per-update callbacks, including close/retry/reset paths, which is complex and can affect correctness of update outcomes and callback delivery. > > **Overview** > Adds **workflow update completion callbacks** via CHASM so Nexus callers can register callbacks on `UpdateWorkflowExecution` and have them fired on update completion or workflow close. > > This introduces a `WorkflowUpdate` CHASM component with new `UpdateState` protobuf (including persisted `rejection_failure`), stores update callbacks under `Workflow.Updates`, and extends callback processing to handle *update-level* callbacks on update completion, rejection (including reset/reapply), and on run transitions (retry/timeout/continue-as-new) where update callbacks must fire even if workflow-level callbacks are inherited. > > It also adds dynamic config gates/limits (`EnableWorkflowUpdateCallbacks`, `MaxCallbacksPerUpdateID`), updates `DescribeWorkflow` to surface update callback triggers, extends mutable state/history builder APIs to carry per-update callback options in `WorkflowExecutionOptionsUpdated`, and adds `Update.AttachCallbacks` logic to persist/flush callbacks (including buffering while `stateSent`, request-id dedup, and stricter validation requiring `request_id` when callbacks are present). > > <sup>Reviewed by [Cursor Bugbot]() for commit 4484fee104d73344ef5370c468008440c7df1c6a. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> --------- Co-authored-by: long-nt-tran <[email protected]>
497 lines
19 KiB
Go
497 lines
19 KiB
Go
package tests
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/suite"
|
|
commonpb "go.temporal.io/api/common/v1"
|
|
enumspb "go.temporal.io/api/enums/v1"
|
|
"go.temporal.io/api/serviceerror"
|
|
updatepb "go.temporal.io/api/update/v1"
|
|
"go.temporal.io/api/workflowservice/v1"
|
|
sdkclient "go.temporal.io/sdk/client"
|
|
"go.temporal.io/sdk/temporal"
|
|
"go.temporal.io/sdk/worker"
|
|
"go.temporal.io/sdk/workflow"
|
|
"go.temporal.io/server/common/dynamicconfig"
|
|
"go.temporal.io/server/common/namespace"
|
|
"go.temporal.io/server/common/testing/testvars"
|
|
"go.temporal.io/server/tests/testcore"
|
|
)
|
|
|
|
var (
|
|
unreachableErr = errors.New("unreachable code")
|
|
)
|
|
|
|
type UpdateWorkflowSdkSuite struct {
|
|
testcore.FunctionalTestBase
|
|
}
|
|
|
|
func TestUpdateWorkflowSdkSuite(t *testing.T) {
|
|
t.Parallel()
|
|
s := new(UpdateWorkflowSdkSuite)
|
|
suite.Run(t, s)
|
|
}
|
|
|
|
func (s *UpdateWorkflowSdkSuite) TestTerminateWorkflowAfterUpdateAdmitted() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
tv := testvars.New(s.T()).
|
|
WithTaskQueue(s.TaskQueue()).
|
|
WithNamespaceName(s.Namespace())
|
|
|
|
workflowFn := func(ctx workflow.Context) error {
|
|
s.NoError(workflow.SetUpdateHandler(ctx, tv.HandlerName(), func(ctx workflow.Context, arg string) error {
|
|
s.NoError(workflow.Await(ctx, func() bool { return false }))
|
|
return unreachableErr
|
|
}))
|
|
s.NoError(workflow.Await(ctx, func() bool { return false }))
|
|
return unreachableErr
|
|
}
|
|
|
|
// Start workflow and wait until update is admitted, without starting the worker
|
|
run := s.startWorkflow(ctx, tv, workflowFn)
|
|
s.updateWorkflowWaitAdmitted(ctx, tv, "update-arg")
|
|
|
|
s.SdkWorker().RegisterWorkflow(workflowFn)
|
|
|
|
s.NoError(s.SdkClient().TerminateWorkflow(ctx, tv.WorkflowID(), run.GetRunID(), "reason"))
|
|
|
|
_, err := s.pollUpdate(ctx, tv, &updatepb.WaitPolicy{LifecycleStage: enumspb.UPDATE_WORKFLOW_EXECUTION_LIFECYCLE_STAGE_ACCEPTED})
|
|
var notFound *serviceerror.NotFound
|
|
s.ErrorAs(err, ¬Found)
|
|
|
|
hist := s.GetHistory(s.Namespace().String(), tv.WorkflowExecution())
|
|
s.EqualHistoryEventsPrefix(`
|
|
1 WorkflowExecutionStarted
|
|
2 WorkflowTaskScheduled`, hist)
|
|
|
|
s.EqualHistoryEventsSuffix(`
|
|
WorkflowExecutionTerminated // This can be EventID=3 if WF is terminated before 1st WFT is started or 5 if after.`, hist)
|
|
}
|
|
|
|
// TestUpdateWorkflow_TimeoutWorkflowAfterUpdateAccepted executes an update, and while WF awaits
|
|
// server times out the WF after the update has been accepted but before it has been completed. It checks
|
|
// that the client gets a NotFound error when attempting to fetch the update result (rather than a timeout).
|
|
func (s *UpdateWorkflowSdkSuite) TestTimeoutWorkflowAfterUpdateAccepted() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
tv := testvars.New(s.T()).
|
|
WithTaskQueue(s.TaskQueue()).
|
|
WithNamespaceName(s.Namespace())
|
|
|
|
workflowFn := func(ctx workflow.Context) error {
|
|
s.NoError(workflow.SetUpdateHandler(ctx, tv.HandlerName(), func(ctx workflow.Context, arg string) error {
|
|
s.NoError(workflow.Await(ctx, func() bool { return false }))
|
|
return unreachableErr
|
|
}))
|
|
s.NoError(workflow.Await(ctx, func() bool { return false }))
|
|
return unreachableErr
|
|
}
|
|
|
|
s.SdkWorker().RegisterWorkflow(workflowFn)
|
|
|
|
wfRun, err := s.SdkClient().ExecuteWorkflow(ctx, sdkclient.StartWorkflowOptions{
|
|
ID: tv.WorkflowID(),
|
|
TaskQueue: tv.TaskQueue().Name,
|
|
WorkflowExecutionTimeout: time.Second,
|
|
}, workflowFn)
|
|
s.NoError(err)
|
|
|
|
// Wait for the first WFT to complete.
|
|
s.WaitForHistoryEvents(`
|
|
1 WorkflowExecutionStarted
|
|
2 WorkflowTaskScheduled
|
|
3 WorkflowTaskStarted
|
|
4 WorkflowTaskCompleted`,
|
|
s.GetHistoryFunc(tv.NamespaceName().String(), tv.WorkflowExecution()),
|
|
1*time.Second, 200*time.Millisecond)
|
|
|
|
updateHandle, err := s.updateWorkflowWaitAccepted(ctx, tv, "my-update-arg")
|
|
s.NoError(err)
|
|
|
|
err = updateHandle.Get(ctx, nil)
|
|
var appErr *temporal.ApplicationError
|
|
s.ErrorAs(err, &appErr)
|
|
s.Contains("Workflow Update failed because the Workflow completed before the Update completed.", appErr.Message())
|
|
|
|
pollFailure, pollErr := s.pollUpdate(ctx, tv, &updatepb.WaitPolicy{LifecycleStage: enumspb.UPDATE_WORKFLOW_EXECUTION_LIFECYCLE_STAGE_COMPLETED})
|
|
s.NoError(pollErr)
|
|
s.Equal("Workflow Update failed because the Workflow completed before the Update completed.", pollFailure.GetOutcome().GetFailure().GetMessage())
|
|
|
|
var wee *temporal.WorkflowExecutionError
|
|
s.ErrorAs(wfRun.Get(ctx, nil), &wee)
|
|
|
|
s.EqualHistoryEvents(`
|
|
1 WorkflowExecutionStarted
|
|
2 WorkflowTaskScheduled
|
|
3 WorkflowTaskStarted
|
|
4 WorkflowTaskCompleted
|
|
5 WorkflowTaskScheduled
|
|
6 WorkflowTaskStarted
|
|
7 WorkflowTaskCompleted
|
|
8 WorkflowExecutionUpdateAccepted
|
|
9 WorkflowExecutionTimedOut`, s.GetHistory(s.Namespace().String(), tv.WorkflowExecution()))
|
|
}
|
|
|
|
// TestUpdateWorkflow_TerminateWorkflowAfterUpdateAccepted executes an update, and while WF awaits
|
|
// server terminates the WF after the update has been accepted but before it has been completed. It checks
|
|
// that the client gets a NotFound error when attempting to fetch the update result (rather than a timeout).
|
|
func (s *UpdateWorkflowSdkSuite) TestTerminateWorkflowAfterUpdateAccepted() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
tv := testvars.New(s.T()).
|
|
WithTaskQueue(s.TaskQueue()).
|
|
WithNamespaceName(namespace.Name(s.Namespace().String()))
|
|
|
|
workflowFn := func(ctx workflow.Context) error {
|
|
s.NoError(workflow.SetUpdateHandler(ctx, tv.HandlerName(), func(ctx workflow.Context, arg string) error {
|
|
s.NoError(workflow.Await(ctx, func() bool { return false }))
|
|
return unreachableErr
|
|
}))
|
|
s.NoError(workflow.Await(ctx, func() bool { return false }))
|
|
return unreachableErr
|
|
}
|
|
|
|
s.SdkWorker().RegisterWorkflow(workflowFn)
|
|
wfRun := s.startWorkflow(ctx, tv, workflowFn)
|
|
|
|
// Wait for the first WFT to complete.
|
|
s.WaitForHistoryEvents(`
|
|
1 WorkflowExecutionStarted
|
|
2 WorkflowTaskScheduled
|
|
3 WorkflowTaskStarted
|
|
4 WorkflowTaskCompleted`,
|
|
s.GetHistoryFunc(tv.NamespaceName().String(), tv.WorkflowExecution()),
|
|
1*time.Second, 200*time.Millisecond)
|
|
|
|
updateHandle, err := s.updateWorkflowWaitAccepted(ctx, tv, "my-update-arg")
|
|
s.NoError(err)
|
|
|
|
s.NoError(s.SdkClient().TerminateWorkflow(ctx, tv.WorkflowID(), wfRun.GetRunID(), "reason"))
|
|
|
|
err = updateHandle.Get(ctx, nil)
|
|
var appErr *temporal.ApplicationError
|
|
s.ErrorAs(err, &appErr)
|
|
s.Contains("Workflow Update failed because the Workflow completed before the Update completed.", appErr.Message())
|
|
|
|
pollFailure, pollErr := s.pollUpdate(ctx, tv, &updatepb.WaitPolicy{LifecycleStage: enumspb.UPDATE_WORKFLOW_EXECUTION_LIFECYCLE_STAGE_COMPLETED})
|
|
s.NoError(pollErr)
|
|
s.Equal("Workflow Update failed because the Workflow completed before the Update completed.", pollFailure.GetOutcome().GetFailure().GetMessage())
|
|
|
|
var wee *temporal.WorkflowExecutionError
|
|
s.ErrorAs(wfRun.Get(ctx, nil), &wee)
|
|
|
|
s.EqualHistoryEvents(`
|
|
1 WorkflowExecutionStarted
|
|
2 WorkflowTaskScheduled
|
|
3 WorkflowTaskStarted
|
|
4 WorkflowTaskCompleted
|
|
5 WorkflowTaskScheduled
|
|
6 WorkflowTaskStarted
|
|
7 WorkflowTaskCompleted
|
|
8 WorkflowExecutionUpdateAccepted
|
|
9 WorkflowExecutionTerminated`, s.GetHistory(s.Namespace().String(), tv.WorkflowExecution()))
|
|
}
|
|
|
|
func (s *UpdateWorkflowSdkSuite) TestContinueAsNewAfterUpdateAdmitted() {
|
|
/*
|
|
Start Workflow and send Update to itself from LA to make sure it is admitted
|
|
by server while WFT is running. This WFT does CAN. For test simplicity,
|
|
it used another WF function for 2nd run. This 2nd function has Update handler
|
|
registered. When server receives CAN it abort all Updates with retryable
|
|
"workflow is closing" error and server internally retries. In the meantime, server process CAN,
|
|
starts 2nd run, Update is delivered to it, and processed by registered handler.
|
|
*/
|
|
|
|
tv := testvars.New(s.T()).
|
|
WithTaskQueue(s.TaskQueue()).
|
|
WithNamespaceName(namespace.Name(s.Namespace().String()))
|
|
|
|
rootCtx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
|
|
sendUpdateActivityFn := func(ctx context.Context) error {
|
|
s.updateWorkflowWaitAdmitted(rootCtx, tv, "update-arg")
|
|
return nil
|
|
}
|
|
|
|
workflowFn2 := func(ctx workflow.Context) error {
|
|
s.NoError(workflow.SetUpdateHandler(ctx, tv.HandlerName(), func(ctx workflow.Context, arg string) (string, error) {
|
|
return workflow.GetInfo(ctx).WorkflowExecution.RunID, nil
|
|
}))
|
|
|
|
s.NoError(workflow.Await(ctx, func() bool { return false }))
|
|
return unreachableErr
|
|
}
|
|
|
|
workflowFn1 := func(ctx workflow.Context) error {
|
|
ctx = workflow.WithLocalActivityOptions(ctx, workflow.LocalActivityOptions{
|
|
StartToCloseTimeout: 5 * time.Second,
|
|
})
|
|
s.NoError(workflow.ExecuteLocalActivity(ctx, sendUpdateActivityFn).Get(ctx, nil))
|
|
|
|
return workflow.NewContinueAsNewError(ctx, workflowFn2)
|
|
}
|
|
|
|
s.SdkWorker().RegisterWorkflow(workflowFn1)
|
|
s.SdkWorker().RegisterWorkflow(workflowFn2)
|
|
s.SdkWorker().RegisterActivity(sendUpdateActivityFn)
|
|
|
|
var firstRun sdkclient.WorkflowRun
|
|
firstRun = s.startWorkflow(rootCtx, tv, workflowFn1)
|
|
var secondRunID string
|
|
s.Eventually(func() bool {
|
|
resp, err := s.pollUpdate(rootCtx, tv, &updatepb.WaitPolicy{LifecycleStage: enumspb.UPDATE_WORKFLOW_EXECUTION_LIFECYCLE_STAGE_COMPLETED})
|
|
if err != nil {
|
|
var notFoundErr *serviceerror.NotFound
|
|
var resourceExhaustedErr *serviceerror.ResourceExhausted
|
|
// If poll lands on 1st run, it will get ResourceExhausted.
|
|
// If poll lands on 2nd run, it will get NotFound error for few attempts.
|
|
// All other errors are unexpected.
|
|
s.True(errors.As(err, ¬FoundErr) || errors.As(err, &resourceExhaustedErr), "error must be NotFound or ResourceExhausted")
|
|
return false
|
|
}
|
|
secondRunID = testcore.DecodeString(s.T(), resp.GetOutcome().GetSuccess())
|
|
return true
|
|
}, 5*time.Second, 100*time.Millisecond, "update did not reach Completed stage")
|
|
|
|
s.NotEqual(firstRun.GetRunID(), secondRunID, "RunId of started WF and WF that received Update should be different")
|
|
|
|
s.EqualHistoryEvents(`
|
|
1 WorkflowExecutionStarted
|
|
2 WorkflowTaskScheduled
|
|
3 WorkflowTaskStarted
|
|
4 WorkflowTaskCompleted
|
|
5 MarkerRecorded
|
|
6 WorkflowExecutionContinuedAsNew`, s.GetHistory(s.Namespace().String(), tv.WithRunID(firstRun.GetRunID()).WorkflowExecution()))
|
|
|
|
hist2 := s.GetHistory(s.Namespace().String(), tv.WithRunID(secondRunID).WorkflowExecution())
|
|
s.EqualHistoryEventsPrefix(`
|
|
1 WorkflowExecutionStarted
|
|
2 WorkflowTaskScheduled
|
|
3 WorkflowTaskStarted
|
|
4 WorkflowTaskCompleted`, hist2)
|
|
s.EqualHistoryEventsSuffix(`
|
|
WorkflowTaskScheduled // This can be EventID=2 if Update is retried before 1st WFT is completed or 5 if 1st WFT completes first.
|
|
WorkflowTaskStarted
|
|
WorkflowTaskCompleted
|
|
WorkflowExecutionUpdateAccepted
|
|
WorkflowExecutionUpdateCompleted`, hist2)
|
|
}
|
|
|
|
func (s *UpdateWorkflowSdkSuite) TestTimeoutWithRetryAfterUpdateAdmitted() {
|
|
/*
|
|
Test ensures that admitted Updates are aborted with retriable error
|
|
when WF times out with retries and carried over to the new run.
|
|
|
|
Send update to WF with short timeout (1s) w/o running worker for this WF. Update gets admitted
|
|
by server but not processed by WF. WF times out, Update is aborted with retriable error,
|
|
server starts new run, and Update is retried on that new run. In the meantime, worker is started
|
|
and catch up the second run.
|
|
*/
|
|
|
|
tv := testvars.New(s.T()).
|
|
WithTaskQueue(s.TaskQueue()).
|
|
WithNamespaceName(namespace.Name(s.Namespace().String()))
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
|
|
workflowFn := func(ctx workflow.Context) error {
|
|
s.NoError(workflow.SetUpdateHandler(ctx, tv.HandlerName(), func(ctx workflow.Context, arg string) (string, error) {
|
|
return workflow.GetInfo(ctx).WorkflowExecution.RunID, nil
|
|
}))
|
|
s.NoError(workflow.Await(ctx, func() bool { return false }))
|
|
return unreachableErr
|
|
}
|
|
|
|
firstRun, err := s.SdkClient().ExecuteWorkflow(ctx, sdkclient.StartWorkflowOptions{
|
|
ID: tv.WorkflowID(),
|
|
TaskQueue: tv.TaskQueue().Name,
|
|
WorkflowRunTimeout: 1 * time.Second,
|
|
RetryPolicy: &temporal.RetryPolicy{
|
|
MaximumAttempts: 2,
|
|
},
|
|
}, workflowFn)
|
|
s.NoError(err)
|
|
s.updateWorkflowWaitAdmitted(ctx, tv, tv.Any().String())
|
|
|
|
err = firstRun.GetWithOptions(ctx, nil, sdkclient.WorkflowRunGetOptions{DisableFollowingRuns: true})
|
|
var canErr *workflow.ContinueAsNewError
|
|
s.ErrorAs(err, &canErr)
|
|
|
|
// "start" worker for workflowFn.
|
|
s.SdkWorker().RegisterWorkflow(workflowFn)
|
|
|
|
var secondRunID string
|
|
s.Eventually(func() bool {
|
|
resp, err := s.pollUpdate(ctx, tv, &updatepb.WaitPolicy{LifecycleStage: enumspb.UPDATE_WORKFLOW_EXECUTION_LIFECYCLE_STAGE_COMPLETED})
|
|
if err != nil {
|
|
var notFoundErr *serviceerror.NotFound
|
|
// If a poll beats internal update retries, it will get NotFound error for a few attempts.
|
|
// All other errors are unexpected.
|
|
s.ErrorAs(err, ¬FoundErr, "error must be NotFound")
|
|
return false
|
|
}
|
|
secondRunID = testcore.DecodeString(s.T(), resp.GetOutcome().GetSuccess())
|
|
s.NotEmpty(secondRunID)
|
|
return true
|
|
}, 5*time.Second, 100*time.Millisecond, "update did not reach Completed stage")
|
|
|
|
s.NotEqual(firstRun.GetRunID(), secondRunID, "RunId of started WF and WF that received Update should be different")
|
|
|
|
s.EqualHistoryEvents(`
|
|
1 WorkflowExecutionStarted
|
|
2 WorkflowTaskScheduled
|
|
3 WorkflowTaskStarted
|
|
4 WorkflowTaskFailed
|
|
5 WorkflowExecutionTimedOut`, s.GetHistory(s.Namespace().String(), tv.WithRunID(firstRun.GetRunID()).WorkflowExecution()))
|
|
s.EqualHistoryEvents(`
|
|
1 WorkflowExecutionStarted
|
|
2 WorkflowTaskScheduled
|
|
3 WorkflowTaskStarted
|
|
4 WorkflowTaskCompleted
|
|
5 WorkflowExecutionUpdateAccepted
|
|
6 WorkflowExecutionUpdateCompleted`, s.GetHistory(s.Namespace().String(), tv.WithRunID(secondRunID).WorkflowExecution()))
|
|
}
|
|
|
|
func (s *UpdateWorkflowSdkSuite) startWorkflow(ctx context.Context, tv *testvars.TestVars, workflowFn any) sdkclient.WorkflowRun {
|
|
run, err := s.SdkClient().ExecuteWorkflow(ctx, sdkclient.StartWorkflowOptions{
|
|
ID: tv.WorkflowID(),
|
|
TaskQueue: tv.TaskQueue().Name,
|
|
}, workflowFn)
|
|
s.NoError(err)
|
|
return run
|
|
}
|
|
|
|
func (s *UpdateWorkflowSdkSuite) updateWorkflowWaitAdmitted(ctx context.Context, tv *testvars.TestVars, arg string) {
|
|
go func() { _, _ = s.updateWorkflowWaitAccepted(ctx, tv, arg) }()
|
|
s.Eventually(func() bool {
|
|
resp, err := s.pollUpdate(ctx, tv, &updatepb.WaitPolicy{LifecycleStage: enumspb.UPDATE_WORKFLOW_EXECUTION_LIFECYCLE_STAGE_ADMITTED})
|
|
if err == nil {
|
|
s.Equal(enumspb.UPDATE_WORKFLOW_EXECUTION_LIFECYCLE_STAGE_ADMITTED, resp.Stage)
|
|
return true
|
|
}
|
|
var notFoundErr *serviceerror.NotFound
|
|
s.ErrorAs(err, ¬FoundErr) // poll beat send in race
|
|
return false
|
|
}, 5*time.Second, 100*time.Millisecond, "update %s did not reach Admitted stage", tv.UpdateID())
|
|
}
|
|
|
|
func (s *UpdateWorkflowSdkSuite) updateWorkflowWaitAccepted(ctx context.Context, tv *testvars.TestVars, arg string) (sdkclient.WorkflowUpdateHandle, error) {
|
|
return s.SdkClient().UpdateWorkflow(ctx, sdkclient.UpdateWorkflowOptions{
|
|
UpdateID: tv.UpdateID(),
|
|
WorkflowID: tv.WorkflowID(),
|
|
RunID: tv.RunID(),
|
|
UpdateName: tv.HandlerName(),
|
|
Args: []any{arg},
|
|
WaitForStage: sdkclient.WorkflowUpdateStageAccepted,
|
|
})
|
|
}
|
|
|
|
func (s *UpdateWorkflowSdkSuite) pollUpdate(ctx context.Context, tv *testvars.TestVars, waitPolicy *updatepb.WaitPolicy) (*workflowservice.PollWorkflowExecutionUpdateResponse, error) {
|
|
return s.SdkClient().WorkflowService().PollWorkflowExecutionUpdate(ctx, &workflowservice.PollWorkflowExecutionUpdateRequest{
|
|
Namespace: tv.NamespaceName().String(),
|
|
UpdateRef: tv.UpdateRef(),
|
|
Identity: tv.ClientIdentity(),
|
|
WaitPolicy: waitPolicy,
|
|
})
|
|
}
|
|
|
|
// TestUpdateSameRequestIDDeduplicatesCallbacks verifies requestID-based
|
|
// deduplication in AttachCallbacks. The update blocks (stays in stateAccepted), then:
|
|
// - A second request with the same requestID is deduped (no new callback).
|
|
// - A third request with a different requestID creates an additional callback.
|
|
//
|
|
// The workflow should end up with exactly 2 update callbacks (from requestID1 and requestID2).
|
|
func (s *UpdateWorkflowSdkSuite) TestUpdateSameRequestIDDeduplicatesCallbacks() {
|
|
s.OverrideDynamicConfig(dynamicconfig.EnableChasm, true)
|
|
s.OverrideDynamicConfig(dynamicconfig.EnableCHASMCallbacks, true)
|
|
s.OverrideDynamicConfig(dynamicconfig.EnableWorkflowUpdateCallbacks, true)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
|
|
taskQueue := testcore.RandomizeStr(s.T().Name())
|
|
updateID := "dedup-callbacks-test"
|
|
requestID1 := uuid.NewString()
|
|
requestID2 := uuid.NewString()
|
|
|
|
// Workflow where the update handler blocks until signaled.
|
|
wf := func(ctx workflow.Context, input string) (string, error) {
|
|
if err := workflow.SetUpdateHandler(ctx, "update", func(ctx workflow.Context, input string) (string, error) {
|
|
signalCh := workflow.GetSignalChannel(ctx, "complete-update")
|
|
signalCh.Receive(ctx, nil)
|
|
return "updated: " + input, nil
|
|
}); err != nil {
|
|
return "", err
|
|
}
|
|
signalCh := workflow.GetSignalChannel(ctx, "stop")
|
|
signalCh.Receive(ctx, nil)
|
|
return "done: " + input, nil
|
|
}
|
|
|
|
w := worker.New(s.SdkClient(), taskQueue, worker.Options{})
|
|
w.RegisterWorkflow(wf)
|
|
s.NoError(w.Start())
|
|
s.T().Cleanup(w.Stop)
|
|
|
|
run, err := s.SdkClient().ExecuteWorkflow(ctx, sdkclient.StartWorkflowOptions{
|
|
ID: testcore.RandomizeStr("wf"),
|
|
TaskQueue: taskQueue,
|
|
}, wf, "input")
|
|
s.NoError(err)
|
|
|
|
makeRequest := func(reqID string) *workflowservice.UpdateWorkflowExecutionRequest {
|
|
return &workflowservice.UpdateWorkflowExecutionRequest{
|
|
Namespace: s.Namespace().String(),
|
|
WorkflowExecution: &commonpb.WorkflowExecution{WorkflowId: run.GetID()},
|
|
WaitPolicy: &updatepb.WaitPolicy{LifecycleStage: enumspb.UPDATE_WORKFLOW_EXECUTION_LIFECYCLE_STAGE_ACCEPTED},
|
|
Request: &updatepb.Request{
|
|
Meta: &updatepb.Meta{UpdateId: updateID},
|
|
Input: &updatepb.Input{Name: "update", Args: &commonpb.Payloads{Payloads: []*commonpb.Payload{testcore.MustToPayload(s.T(), "test")}}},
|
|
RequestId: reqID,
|
|
CompletionCallbacks: []*commonpb.Callback{{
|
|
Variant: &commonpb.Callback_Nexus_{Nexus: &commonpb.Callback_Nexus{Url: "http://localhost:9999/callback"}},
|
|
}},
|
|
},
|
|
}
|
|
}
|
|
|
|
// First request: triggers the update, waits for acceptance (update blocks in handler).
|
|
_, err = s.FrontendClient().UpdateWorkflowExecution(ctx, makeRequest(requestID1))
|
|
s.NoError(err)
|
|
|
|
// Second request: same requestID → should be deduped by AttachCallbacks (no new callback).
|
|
_, err = s.FrontendClient().UpdateWorkflowExecution(ctx, makeRequest(requestID1))
|
|
s.NoError(err)
|
|
|
|
// Third request: different requestID → should create a new callback via AttachCallbacks.
|
|
_, err = s.FrontendClient().UpdateWorkflowExecution(ctx, makeRequest(requestID2))
|
|
s.NoError(err)
|
|
|
|
// Verify exactly 2 update callbacks: one from requestID1 (first request),
|
|
// one from requestID2 (third request). The second request was deduped.
|
|
descResp, err := s.FrontendClient().DescribeWorkflowExecution(ctx, &workflowservice.DescribeWorkflowExecutionRequest{
|
|
Namespace: s.Namespace().String(),
|
|
Execution: &commonpb.WorkflowExecution{WorkflowId: run.GetID()},
|
|
})
|
|
s.NoError(err)
|
|
updateCallbackCount := 0
|
|
for _, cb := range descResp.GetCallbacks() {
|
|
if cb.GetTrigger().GetUpdateWorkflowExecutionCompleted() != nil {
|
|
updateCallbackCount++
|
|
}
|
|
}
|
|
s.Equal(2, updateCallbackCount, "expected 2 callbacks: requestID1 (original) + requestID2 (new), with duplicate requestID1 deduped")
|
|
|
|
// Clean up.
|
|
s.NoError(s.SdkClient().SignalWorkflow(ctx, run.GetID(), run.GetRunID(), "complete-update", nil))
|
|
s.NoError(s.SdkClient().SignalWorkflow(ctx, run.GetID(), run.GetRunID(), "stop", nil))
|
|
}
|