Files
tasks/tests/update_workflow_sdk_test.go
f97a858310 Callback for workflow update support (#9614)
## 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]>
2026-05-19 18:31:15 -04:00

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, &notFound)
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, &notFoundErr) || 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, &notFoundErr, "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, &notFoundErr) // 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))
}