Files
temporal/tests/activity_workflow_driver.go
Stephan Behnke 5c210f4c73 Replace errors.As with errors.AsType (#11674)
Go 1.27 prerequisite that applies the `errorsastype` Go fixer and its
required error-interface updates.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-25 09:04:00 -07:00

385 lines
15 KiB
Go

package tests
// Driver for workflow-activity (WFA) tests: it drives an activity scheduled by a workflow through a
// sequence of events (a 'trace'). Each event is either a frontend RPC, a poll, or a timer
// wait. The event vocabulary is in chasm/lib/activity/model.
import (
"cmp"
"context"
"errors"
"fmt"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
activitypb "go.temporal.io/api/activity/v1"
enumspb "go.temporal.io/api/enums/v1"
taskqueuepb "go.temporal.io/api/taskqueue/v1"
workflowpb "go.temporal.io/api/workflow/v1"
"go.temporal.io/api/workflowservice/v1"
sdkclient "go.temporal.io/sdk/client"
"go.temporal.io/sdk/temporal"
sdkworker "go.temporal.io/sdk/worker"
"go.temporal.io/sdk/workflow"
"go.temporal.io/server/chasm/lib/activity/model"
"go.temporal.io/server/common/payloads"
"go.temporal.io/server/common/testing/await"
"go.temporal.io/server/common/testing/testcontext"
"go.temporal.io/server/tests/testcore"
"google.golang.org/protobuf/types/known/durationpb"
"google.golang.org/protobuf/types/known/fieldmaskpb"
"google.golang.org/protobuf/types/known/timestamppb"
)
type wfaDriver struct {
env *testcore.TestEnv
t *testing.T
cfg activityConfig
}
// newWFADriver builds a driver. cfg.StartDelay is ignored: a workflow activity has no per-activity
// start delay.
func newWFADriver(t *testing.T, env *testcore.TestEnv, cfg activityConfig) *wfaDriver {
return &wfaDriver{env: env, t: t, cfg: cfg}
}
// testContext returns the driver's current test context, deliberately not cached;
// see [testcontext.EnsureRemaining].
func (d *wfaDriver) testContext() context.Context {
return testcontext.For(d.t)
}
// wfaHandle is a handle to a workflow-scheduled activity.
type wfaHandle struct {
activityDriverState
d *wfaDriver
run sdkclient.WorkflowRun
workflowID string
runID string
activityID string
taskQueue string
}
// driveTrace starts a workflow, which schedules an activity, and then advances that activity
// through a sequence of events (a 'trace'). Returns a handle to the activity at the reached state.
func (d *wfaDriver) driveTrace(t *testing.T, trace []model.Event) *wfaHandle {
validateTrace(t, trace)
a := d.start(t, d.cfg.forTrace(trace))
for _, e := range trace {
a.driveEvent(t, e)
}
return a
}
func (a *wfaHandle) driveEvent(t testing.TB, e model.Event) {
driveActivityEvent(t, a, e)
}
func (a *wfaHandle) testContext() context.Context {
return a.d.testContext()
}
func (a *wfaHandle) awaitTimeout(t testing.TB, e model.Event, deadline time.Time) {
awaitActivityTimeout(t, a, e, deadline)
}
// timeoutInfo is the most recent timeout the activity reports. DescribeWorkflowExecution exposes the
// last failure only while the activity is in progress; once it closes, the timeout comes from the
// workflow result instead.
func (a *wfaHandle) timeoutInfo(t require.TestingT) activityTimeoutInfo {
if pa := a.pendingActivityInfo(t); pa != nil {
return activityTimeoutInfo{
timeout: pa.GetLastFailure().GetTimeoutFailureInfo().GetTimeoutType(),
attempt: pa.GetAttempt(),
}
}
if timeoutErr, ok := errors.AsType[*temporal.TimeoutError](a.run.Get(a.testContext(), nil)); ok {
return activityTimeoutInfo{timeout: timeoutErr.TimeoutType(), terminal: true}
}
return activityTimeoutInfo{terminal: true}
}
// awaitDispatchDelay waits for the public dispatch deadline to become due. A following Poll is what
// proves that the task actually reached Matching.
func (a *wfaHandle) awaitDispatchDelay(t testing.TB, e model.Event) {
awaitActivityDispatchDelay(a.testContext(), t, e, func(t require.TestingT) (bool, enumspb.PendingActivityState, *timestamppb.Timestamp, any) {
pa := a.pendingActivityInfo(t)
if pa == nil {
return false, enumspb.PENDING_ACTIVITY_STATE_UNSPECIFIED, nil, "activity is no longer in progress"
}
return true,
pa.GetState(),
pa.GetNextAttemptScheduleTime(),
wfaActivityInfo(pa)
})
}
func (d *wfaDriver) start(t *testing.T, cfg activityConfig) *wfaHandle {
wfTQ := testcore.RandomizeStr("wfa-wf")
actTQ := testcore.RandomizeStr("wfa-act")
const actID = "act"
// Run a workflow worker for the wrapper workflow, but not an activity worker: the tests poll
// for activity tasks.
w := sdkworker.New(d.env.SdkClient(), wfTQ, sdkworker.Options{})
w.RegisterWorkflow(wfaSingleActivityWorkflow)
require.NoError(t, w.Start())
t.Cleanup(w.Stop)
wfID := testcore.RandomizeStr("wfa-run")
run, err := d.env.SdkClient().ExecuteWorkflow(d.testContext(),
sdkclient.StartWorkflowOptions{ID: wfID, TaskQueue: wfTQ},
wfaSingleActivityWorkflow, wfaActivityParams{Cfg: cfg, ActivityTQ: actTQ, ActivityID: actID})
require.NoError(t, err)
a := &wfaHandle{
activityDriverState: activityDriverState{cfg: cfg},
d: d,
run: run,
workflowID: wfID,
runID: run.GetRunID(),
activityID: actID,
taskQueue: actTQ,
}
// The workflow schedules the activity, so it does not exist yet when ExecuteWorkflow returns.
await.Require(d.testContext(), t, func(t *await.T) {
_, activityInProgress := a.activityInfoIfInProgress(t)
t.Require().True(activityInProgress, "the workflow has not scheduled its activity")
}, activityDriverTimeout, activityDriverPollInterval)
return a
}
// wfaActivityParams is what the helper workflow needs to schedule the activity: the activity the
// test described, and where to put it.
type wfaActivityParams struct {
Cfg activityConfig
ActivityTQ string
ActivityID string
}
// wfaCancelSignal makes the helper workflow cancel the activity, which is how a workflow activity is
// cancelled rather than by a direct RPC.
const wfaCancelSignal = "cancel"
// wfaSingleActivityWorkflow is a workflow that schedules a single activity with the given options
// on its own task queue and waits for it to finish. No worker executes the activity — the test
// drives it with worker poll RPCs. WaitForCancellation makes the workflow wait for
// RespondActivityTaskCanceled, so a cancelled activity reaches CANCELED before the workflow closes.
func wfaSingleActivityWorkflow(ctx workflow.Context, params wfaActivityParams) error {
c := params.Cfg
actCtx, cancelActivity := workflow.WithCancel(ctx)
actCtx = workflow.WithActivityOptions(actCtx, workflow.ActivityOptions{
TaskQueue: params.ActivityTQ,
ActivityID: params.ActivityID,
StartToCloseTimeout: c.startToClose(),
ScheduleToCloseTimeout: c.ScheduleToClose,
ScheduleToStartTimeout: c.ScheduleToStart,
HeartbeatTimeout: c.HeartbeatTimeout,
WaitForCancellation: true,
RetryPolicy: &temporal.RetryPolicy{
InitialInterval: c.retryInterval(),
BackoffCoefficient: cmp.Or(c.BackoffCoefficient, 1.0),
MaximumInterval: cmp.Or(c.MaxRetryInterval, c.retryInterval()),
MaximumAttempts: c.MaxAttempts,
NonRetryableErrorTypes: c.NonRetryableErrorTypes,
},
})
fut := workflow.ExecuteActivity(actCtx, "testWFA", activityInput)
workflow.Go(ctx, func(gctx workflow.Context) {
workflow.GetSignalChannel(gctx, wfaCancelSignal).Receive(gctx, nil)
cancelActivity()
})
return fut.Get(ctx, nil)
}
// pendingActivityInfo is the activity's entry in the workflow's pending set, nil once it is no longer
// pending.
func (a *wfaHandle) pendingActivityInfo(t require.TestingT) *workflowpb.PendingActivityInfo {
resp, err := a.d.env.SdkClient().DescribeWorkflowExecution(a.testContext(), a.workflowID, a.runID)
require.NoError(t, err)
for _, pa := range resp.GetPendingActivities() {
if pa.GetActivityId() == a.activityID {
return pa
}
}
return nil
}
// activityInfo is the activity's PendingActivityInfo, projected down to a schema shared with
// standalone activity.
func (a *wfaHandle) activityInfo(t require.TestingT) activityInfo {
info, activityInProgress := a.activityInfoIfInProgress(t)
require.Truef(t, activityInProgress, "activity %q is no longer in progress; workflow may have closed", a.activityID)
return info
}
// terminalOutcome waits for the activity to reach a terminal state and reports it. A workflow activity's
// terminal status is not in PendingActivities, so it is read from the workflow-result error's cause.
func (a *wfaHandle) terminalOutcome(t require.TestingT) activityTerminalOutcome {
err := a.run.Get(a.testContext(), nil)
if err == nil {
return activityTerminalOutcome{status: enumspb.ACTIVITY_EXECUTION_STATUS_COMPLETED}
}
// A canceled activity is returned as a bare CanceledError, not wrapped in an ActivityError.
if _, ok := errors.AsType[*temporal.CanceledError](err); ok {
return activityTerminalOutcome{status: enumspb.ACTIVITY_EXECUTION_STATUS_CANCELED}
}
var actErr *temporal.ActivityError
require.ErrorAs(t, err, &actErr)
outcome := activityTerminalOutcome{
status: enumspb.ACTIVITY_EXECUTION_STATUS_FAILED,
retryState: actErr.RetryState(),
}
if _, ok := actErr.Unwrap().(*temporal.TimeoutError); ok {
outcome.status = enumspb.ACTIVITY_EXECUTION_STATUS_TIMED_OUT
}
return outcome
}
// activityInfoIfInProgress returns the shared activity projection and whether the activity still has
// a nonterminal execution.
func (a *wfaHandle) activityInfoIfInProgress(t require.TestingT) (activityInfo, bool) {
pendingActivity := a.pendingActivityInfo(t)
if pendingActivity == nil {
return activityInfo{}, false
}
return wfaActivityInfo(pendingActivity), true
}
// wfaActivityInfo converts PendingActivityInfo to the projection shared by both the WFA and SAA
// drivers.
func wfaActivityInfo(p *workflowpb.PendingActivityInfo) activityInfo {
return activityInfo{
RunState: p.GetState(),
Attempt: p.GetAttempt(),
CurrentRetryInterval: p.GetCurrentRetryInterval().AsDuration().Round(time.Second),
NextAttemptScheduleTimeSet: p.GetNextAttemptScheduleTime() != nil,
LastHeartbeatDetails: activityMarshalPayloads(p.GetHeartbeatDetails()),
}
}
// waitForCancelRequested waits until the workflow-initiated cancellation reaches the activity.
func (a *wfaHandle) waitForCancelRequested(t testing.TB) {
await.Require(a.testContext(), t, func(t *await.T) {
pendingActivity := a.pendingActivityInfo(t)
t.Require().NotNil(pendingActivity, "activity is no longer in progress")
t.Require().Equal(enumspb.PENDING_ACTIVITY_STATE_CANCEL_REQUESTED, pendingActivity.GetState())
}, activityDriverTimeout, activityDriverPollInterval)
}
func (a *wfaHandle) respondCanceledByID() error {
_, err := a.d.env.FrontendClient().RespondActivityTaskCanceledById(
a.testContext(),
&workflowservice.RespondActivityTaskCanceledByIdRequest{
Namespace: a.d.env.Namespace().String(),
WorkflowId: a.workflowID,
ActivityId: a.activityID,
RunId: a.runID,
Identity: a.d.env.Tv().WorkerIdentity(),
},
)
return err
}
// rpc performs the frontend RPC for a non-Poll, non-timer event and returns its error.
func (a *wfaHandle) rpc(t testing.TB, e model.Event) error {
fc := a.d.env.FrontendClient()
ns := a.d.env.Namespace().String()
ctx := a.testContext()
switch e.Type {
case model.HeartbeatType:
_, err := fc.RecordActivityTaskHeartbeat(ctx, &workflowservice.RecordActivityTaskHeartbeatRequest{
Namespace: ns, TaskToken: a.token, Details: activityRecordedHeartbeatDetails,
})
return err
case model.RespondCompletedType:
_, err := fc.RespondActivityTaskCompleted(ctx, &workflowservice.RespondActivityTaskCompletedRequest{
Namespace: ns, TaskToken: a.token, Identity: a.d.env.Tv().WorkerIdentity(),
Result: payloads.EncodeString("result"),
})
return err
case model.RespondCompletedByIDType:
_, err := fc.RespondActivityTaskCompletedById(ctx, &workflowservice.RespondActivityTaskCompletedByIdRequest{
Namespace: ns, WorkflowId: a.workflowID, RunId: a.runID, ActivityId: a.activityID, Identity: a.d.env.Tv().WorkerIdentity(),
Result: payloads.EncodeString("result"),
})
return err
case model.RespondFailedType:
req := &workflowservice.RespondActivityTaskFailedRequest{
Namespace: ns, TaskToken: a.token, Identity: a.d.env.Tv().WorkerIdentity(), Failure: respondFailedFailure(e, a.cfg.NextRetryDelay),
}
if e.HasHeartbeatDetails {
req.LastHeartbeatDetails = activityHeartbeatDetails
}
_, err := fc.RespondActivityTaskFailed(ctx, req)
return err
case model.RespondFailedByIDType:
req := &workflowservice.RespondActivityTaskFailedByIdRequest{
Namespace: ns, WorkflowId: a.workflowID, RunId: a.runID, ActivityId: a.activityID, Identity: a.d.env.Tv().WorkerIdentity(),
Failure: respondFailedFailure(e, a.cfg.NextRetryDelay),
}
if e.HasHeartbeatDetails {
req.LastHeartbeatDetails = activityHeartbeatDetails
}
_, err := fc.RespondActivityTaskFailedById(ctx, req)
return err
case model.RespondCanceledType:
_, err := fc.RespondActivityTaskCanceled(ctx, &workflowservice.RespondActivityTaskCanceledRequest{
Namespace: ns, TaskToken: a.token, Identity: a.d.env.Tv().WorkerIdentity(),
})
return err
case model.RequestCancelType:
if err := a.d.env.SdkClient().SignalWorkflow(
ctx,
a.workflowID,
a.runID,
wfaCancelSignal,
nil,
); err != nil {
return err
}
a.waitForCancelRequested(t)
return nil
case model.PauseType:
_, err := fc.PauseActivityExecution(ctx, &workflowservice.PauseActivityExecutionRequest{
Namespace: ns, WorkflowId: a.workflowID, ActivityId: a.activityID, RunId: a.runID, Identity: a.d.env.Tv().ClientIdentity(), Reason: "drive", RequestId: uuid.NewString(),
})
return err
case model.UnpauseType:
_, err := fc.UnpauseActivityExecution(ctx, &workflowservice.UnpauseActivityExecutionRequest{
Namespace: ns, WorkflowId: a.workflowID, ActivityId: a.activityID, RunId: a.runID, Identity: a.d.env.Tv().ClientIdentity(),
})
return err
case model.ResetType:
_, err := fc.ResetActivityExecution(ctx, &workflowservice.ResetActivityExecutionRequest{
Namespace: ns, WorkflowId: a.workflowID, ActivityId: a.activityID, RunId: a.runID, Identity: a.d.env.Tv().ClientIdentity(), KeepPaused: e.KeepPaused, ResetHeartbeat: e.ResetHeartbeat,
})
return err
case model.UpdateOptionsType:
_, err := fc.UpdateActivityExecutionOptions(ctx, &workflowservice.UpdateActivityExecutionOptionsRequest{
Namespace: ns, WorkflowId: a.workflowID, ActivityId: a.activityID, RunId: a.runID, Identity: a.d.env.Tv().ClientIdentity(),
ActivityOptions: &activitypb.ActivityOptions{HeartbeatTimeout: durationpb.New(time.Hour)},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"heartbeat_timeout"}},
})
return err
default:
return fmt.Errorf("wfaDriver: unhandled event type %v", e.Type)
}
}
func (a *wfaHandle) pollForTask(t require.TestingT, timeout time.Duration) *workflowservice.PollActivityTaskQueueResponse {
ctx, cancel := context.WithTimeout(a.testContext(), timeout)
defer cancel()
resp, err := a.d.env.FrontendClient().PollActivityTaskQueue(ctx, &workflowservice.PollActivityTaskQueueRequest{
Namespace: a.d.env.Namespace().String(),
TaskQueue: &taskqueuepb.TaskQueue{Name: a.taskQueue},
Identity: a.d.env.Tv().WorkerIdentity(),
})
require.NoError(t, err)
if resp.GetActivityId() == "" {
return nil
}
return resp
}