Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
bb4ae6c
Add eager start support for standalone activities
wenlong-gu Oct 2, 2026
7e83905
Add eager activity parity coverage
wenlong-gu Oct 2, 2026
b02e5d6
Merge remote-tracking branch 'origin/main' into codex/saa-eager-start…
wenlong-gu Oct 2, 2026
76f9b8c
Use eager activity API dependency
wenlong-gu Oct 5, 2026
d9e4ced
Address eager standalone activity review comments
wenlong-gu Oct 5, 2026
a4e52c3
Fix mixed brain module dependencies
wenlong-gu Oct 5, 2026
7bb39d1
Support temporary API dependency replacements
wenlong-gu Oct 5, 2026
d69cb31
Format eager activity metrics
wenlong-gu Oct 5, 2026
b7bd052
Build eager activity task response transactionally
wenlong-gu Oct 7, 2026
ef78df7
Align eager activity execution metrics
wenlong-gu Oct 7, 2026
bb028b0
Honor Matching grants for eager activities
wenlong-gu Oct 7, 2026
969beae
Test eager activity response loss recovery
wenlong-gu Oct 7, 2026
aa3ef43
Clean up standalone activity state transitions
wenlong-gu Oct 7, 2026
dbc41ea
Resolve eager activity lint findings
wenlong-gu Oct 7, 2026
1bab344
Remove temporary API dependency scaffolding
wenlong-gu Oct 7, 2026
8af210a
Update API dependency after eager activity API merge
wenlong-gu Oct 8, 2026
e75545e
Merge upstream main into eager standalone activity start
wenlong-gu Oct 8, 2026
ec8317d
Merge upstream main into eager standalone activity start
wenlong-gu Oct 10, 2026
8b891d3
Persist eager activity acceptance state
wenlong-gu Oct 12, 2026
0242b8f
Add eager dispatch observability
wenlong-gu Oct 12, 2026
1f944bf
Simplify eager dispatch reconciliation TODO
wenlong-gu Oct 12, 2026
3ca4494
Align SAA eager dispatch metrics
wenlong-gu Oct 12, 2026
296e0a0
Revert "Add eager dispatch observability"
wenlong-gu Oct 13, 2026
7e193e3
Share eager dispatch grant helper
wenlong-gu Oct 13, 2026
e1eddc1
Simplify eager dispatch helpers
wenlong-gu Oct 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions chasm/lib/activity/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ var (
`Allows non-zero start_delay on StartActivityExecution requests.`,
)

EnableEagerStart = dynamicconfig.NewNamespaceBoolSetting(
"activity.enableEagerStart",
true,
`Allows the first standalone activity task to be returned directly by StartActivityExecution.`,
)

EnableCallbacks = dynamicconfig.NewNamespaceBoolSetting(
"activity.enableCallbacks",
false,
Expand All @@ -61,8 +67,10 @@ type Config struct {
BlobSizeLimitWarn dynamicconfig.IntPropertyFnWithNamespaceFilter
BreakdownMetricsByTaskQueue dynamicconfig.TypedPropertyFnWithTaskQueueFilter[bool]
EnableCallbacks dynamicconfig.BoolPropertyFnWithNamespaceFilter
EnableActivityEagerDispatchCheck dynamicconfig.BoolPropertyFnWithNamespaceFilter
EnabledCallbackKinds dynamicconfig.TypedPropertyFnWithNamespaceFilter[[]callbacks.Kind]
Enabled dynamicconfig.BoolPropertyFnWithNamespaceFilter
EnableEagerStart dynamicconfig.BoolPropertyFnWithNamespaceFilter
EnableStandaloneActivityOperatorCommands dynamicconfig.BoolPropertyFnWithNamespaceFilter
LongPollBuffer dynamicconfig.DurationPropertyFnWithNamespaceFilter
LongPollTimeout dynamicconfig.DurationPropertyFnWithNamespaceFilter
Expand All @@ -82,9 +90,11 @@ func ConfigProvider(dc *dynamicconfig.Collection) *Config {
BlobSizeLimitWarn: dynamicconfig.BlobSizeLimitWarn.Get(dc),
BreakdownMetricsByTaskQueue: dynamicconfig.MetricsBreakdownByTaskQueue.Get(dc),
DefaultActivityRetryPolicy: dynamicconfig.DefaultActivityRetryPolicy.Get(dc),
EnableActivityEagerDispatchCheck: dynamicconfig.EnableActivityEagerDispatchCheck.Get(dc),
EnableCallbacks: EnableCallbacks.Get(dc),
EnabledCallbackKinds: EnabledCallbackKinds.Get(dc),
Enabled: Enabled.Get(dc),
EnableEagerStart: EnableEagerStart.Get(dc),
EnableStandaloneActivityOperatorCommands: EnableStandaloneActivityOperatorCommands.Get(dc),
LongPollBuffer: LongPollBuffer.Get(dc),
LongPollTimeout: LongPollTimeout.Get(dc),
Expand Down
5 changes: 4 additions & 1 deletion chasm/lib/activity/frontend.go
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,10 @@ func (h *frontendHandler) validateAndPopulateStartRequest(
if req.GetStartDelay().AsDuration() > 0 && !h.config.StartDelayEnabled(req.GetNamespace()) {
return nil, serviceerror.NewInvalidArgument("start_delay is not enabled for this namespace")
}
// TODO(saa): when eager start is supported, deny it if start delay > 0 (same as workflow behavior).
if req.GetRequestEagerExecution() &&
(!h.config.EnableEagerStart(req.GetNamespace()) || req.GetStartDelay().AsDuration() > 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

small — SAA eager start ignores system.enableActivityEagerExecution, which still turns off eager activities for workflows.

The WFA path only allows eager dispatch when handler.config.EnableActivityEagerExecution(namespace) is true (workflow_task_completed_handler.go:544). The SAA path checks only the new activity.enableEagerStart here, and EnableActivityEagerDispatchCheck in history. An operator who sets system.enableActivityEagerExecution=false to stop all eager activity dispatch for a namespace still gets eager SAA tasks. They also have to know about a second key. The parity test sets both keys, so it does not catch this.

Suggestion: Require both keys, or document that activity.enableEagerStart is the only SAA switch. To require both, add EnableActivityEagerExecution to the activity Config and check it here:

Suggested change
(!h.config.EnableEagerStart(req.GetNamespace()) || req.GetStartDelay().AsDuration() > 0) {
(!h.config.EnableActivityEagerExecution(req.GetNamespace()) || !h.config.EnableEagerStart(req.GetNamespace()) || req.GetStartDelay().AsDuration() > 0) {

req.RequestEagerExecution = false
}

opts := activityOptionsFromStartRequest(req)
err := ValidateAndNormalizeStandaloneActivity(
Expand Down
56 changes: 56 additions & 0 deletions chasm/lib/activity/frontend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"go.temporal.io/api/workflowservice/v1"
"go.temporal.io/server/common/dynamicconfig"
"go.temporal.io/server/common/log"
"go.temporal.io/server/common/metrics"
"go.temporal.io/server/common/namespace"
"google.golang.org/protobuf/types/known/durationpb"
)
Expand Down Expand Up @@ -215,3 +216,58 @@ func TestRequestIdStableAcrossRetries(t *testing.T) {
})
})
}

func TestEagerStartFallback(t *testing.T) {
newHandler := func(eagerEnabled bool) *frontendHandler {
return &frontendHandler{
config: &Config{
BlobSizeLimitError: defaultBlobSizeLimitError,
BlobSizeLimitWarn: defaultBlobSizeLimitWarn,
DefaultActivityRetryPolicy: getDefaultRetrySettings,
EnableEagerStart: dynamicconfig.GetBoolPropertyFnFilteredByNamespace(eagerEnabled),
MaxIDLengthLimit: func() int { return defaultMaxIDLengthLimit },
MaxUserMetadataDetailsSize: defaultMaxUserMetadataDetailsSize,
MaxUserMetadataSummarySize: defaultMaxUserMetadataSummarySize,
StartDelayEnabled: dynamicconfig.GetBoolPropertyFnFilteredByNamespace(true),
},
linkValidator: newLinkValidator(
defaultMaxLinksPerRequest,
func(string) int { return 2000 },
defaultLinkMaxSize,
),
logger: log.NewNoopLogger(),
metricsHandler: metrics.NoopMetricsHandler,
}
}

newRequest := func() *workflowservice.StartActivityExecutionRequest {
return &workflowservice.StartActivityExecutionRequest{
Namespace: "test-namespace",
RequestEagerExecution: true,
ActivityId: "test-activity",
ActivityType: &commonpb.ActivityType{Name: "test-type"},
TaskQueue: &taskqueuepb.TaskQueue{Name: "test-queue"},
StartToCloseTimeout: durationpb.New(time.Minute),
}
}

t.Run("enabled", func(t *testing.T) {
req, err := newHandler(true).validateAndPopulateStartRequest(context.Background(), newRequest(), "test-namespace-id")
require.NoError(t, err)
require.True(t, req.GetRequestEagerExecution())
})

t.Run("namespace disabled", func(t *testing.T) {
req, err := newHandler(false).validateAndPopulateStartRequest(context.Background(), newRequest(), "test-namespace-id")
require.NoError(t, err)
require.False(t, req.GetRequestEagerExecution())
})

t.Run("start delay", func(t *testing.T) {
req := newRequest()
req.StartDelay = durationpb.New(time.Minute)
req, err := newHandler(true).validateAndPopulateStartRequest(context.Background(), req, "test-namespace-id")
require.NoError(t, err)
require.False(t, req.GetRequestEagerExecution())
})
}
18 changes: 14 additions & 4 deletions chasm/lib/activity/gen/activitypb/v1/activity_state.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

144 changes: 113 additions & 31 deletions chasm/lib/activity/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ import (
"go.temporal.io/server/api/historyservice/v1"
"go.temporal.io/server/chasm"
"go.temporal.io/server/chasm/lib/activity/gen/activitypb/v1"
"go.temporal.io/server/common"
"go.temporal.io/server/common/contextutil"
"go.temporal.io/server/common/log"
"go.temporal.io/server/common/metrics"
"go.temporal.io/server/common/namespace"
commonnexus "go.temporal.io/server/common/nexus"
"go.temporal.io/server/common/resource"
)

var (
Expand All @@ -38,6 +40,7 @@ type handler struct {
linkValidator *linkValidator
logger log.Logger
metricsHandler metrics.Handler
matchingClient resource.MatchingClient
namespaceRegistry namespace.Registry
}

Expand All @@ -47,6 +50,7 @@ func newHandler(
linkValidator *linkValidator,
metricsHandler metrics.Handler,
logger log.Logger,
matchingClient resource.MatchingClient,
namespaceRegistry namespace.Registry,
) *handler {
return &handler{
Expand All @@ -55,6 +59,7 @@ func newHandler(
linkValidator: linkValidator,
logger: logger,
metricsHandler: metricsHandler,
matchingClient: matchingClient,
namespaceRegistry: namespaceRegistry,
}
}
Expand All @@ -64,18 +69,22 @@ func newHandler(
// matching being delivered to a worker poll request.
func (h *handler) StartActivityExecution(ctx context.Context, req *activitypb.StartActivityExecutionRequest) (*activitypb.StartActivityExecutionResponse, error) {
frontendReq := req.GetFrontendRequest()
var eagerTaskData *eagerActivityTaskData

reusePolicy, ok := businessIDReusePolicyMap[frontendReq.GetIdReusePolicy()]
if !ok {
return nil, serviceerror.NewInvalidArgumentf("unsupported ID reuse policy: %v", frontendReq.GetIdReusePolicy())
}

conflictPolicy, ok := businessIDConflictPolicyMap[frontendReq.GetIdConflictPolicy()]
if !ok {
return nil, serviceerror.NewInvalidArgumentf("unsupported ID conflict policy: %v", frontendReq.GetIdConflictPolicy())
reusePolicy, conflictPolicy, err := businessIDPolicies(frontendReq)
if err != nil {
return nil, err
}

maxCallbacks := h.config.MaxCallbacksPerExecution(frontendReq.GetNamespace())
// TODO: Reconcile the Matching grant when the subsequent start does not create an eager task.
if frontendReq.GetRequestEagerExecution() &&
h.config.EnableActivityEagerDispatchCheck(frontendReq.GetNamespace()) &&
!h.grantEagerActivityDispatch(ctx, req.GetNamespaceId(), frontendReq) {
// Matching grants are best-effort. On a denial or any Matching failure, schedule the
// activity normally instead of failing the start request.
frontendReq.RequestEagerExecution = false
}

result, err := chasm.StartExecution(
ctx,
Expand All @@ -84,27 +93,11 @@ func (h *handler) StartActivityExecution(ctx context.Context, req *activitypb.St
BusinessID: frontendReq.GetActivityId(),
},
func(mutableContext chasm.MutableContext, request *workflowservice.StartActivityExecutionRequest) (*Activity, error) {
newActivity, err := NewStandaloneActivity(mutableContext, request)
if err != nil {
return nil, err
}

if cbs := request.GetCompletionCallbacks(); len(cbs) > 0 {
if err := newActivity.addCompletionCallbacks(mutableContext, request.GetRequestId(), cbs, maxCallbacks); err != nil {
return nil, err
}
}
if len(request.GetLinks()) > 0 {
if err := newActivity.attachLinks(mutableContext, request.GetLinks(), request.GetRequestId(), h.linkValidator, frontendReq.GetNamespace()); err != nil {
return nil, err
}
}

err = TransitionScheduled.Apply(newActivity, mutableContext, nil)
newActivity, newEagerTaskData, err := h.newActivityForStartExecution(mutableContext, request, req.GetNamespaceId(), maxCallbacks)
if err != nil {
return nil, err
}

eagerTaskData = newEagerTaskData
return newActivity, nil
},
frontendReq,
Expand All @@ -114,9 +107,12 @@ func (h *handler) StartActivityExecution(ctx context.Context, req *activitypb.St

if err != nil {
if alreadyStartedErr, ok := errors.AsType[*chasm.ExecutionAlreadyStartedError](err); ok {
return nil, serviceerror.NewActivityExecutionAlreadyStarted("activity execution already started", alreadyStartedErr.CurrentRequestID, alreadyStartedErr.CurrentRunID)
return nil, serviceerror.NewActivityExecutionAlreadyStarted(
"activity execution already started",
alreadyStartedErr.CurrentRequestID,
alreadyStartedErr.CurrentRunID,
)
}

return nil, err
}

Expand All @@ -130,6 +126,15 @@ func (h *handler) StartActivityExecution(ctx context.Context, req *activitypb.St
)
}

var eagerTask *workflowservice.PollActivityTaskQueueResponse
if result.Created && eagerTaskData != nil {
eagerTask, err = eagerTaskData.response(result.ExecutionRef)
if err != nil {
return nil, err
}
metrics.ActivityEagerExecutionCounter.With(eagerActivityMetricsHandler(h.metricsHandler, h.config, frontendReq)).Record(1)
}

// Apply on_conflict_options to an existing activity.
// TODO: Use chasm.UpdateWithStartExecution to avoid a second transaction once the engine supports BusinessIDConflictPolicyFail in the updateFn path.
cbs := frontendReq.GetCompletionCallbacks()
Expand Down Expand Up @@ -176,8 +181,9 @@ func (h *handler) StartActivityExecution(ctx context.Context, req *activitypb.St

return &activitypb.StartActivityExecutionResponse{
FrontendResponse: &workflowservice.StartActivityExecutionResponse{
RunId: result.ExecutionKey.RunID,
Started: result.Created,
RunId: result.ExecutionKey.RunID,
Started: result.Created,
EagerActivityTask: eagerTask,
Link: &commonpb.Link{
Variant: &commonpb.Link_Activity_{
Activity: &commonpb.Link_Activity{
Expand All @@ -187,11 +193,87 @@ func (h *handler) StartActivityExecution(ctx context.Context, req *activitypb.St
},
},
},
// EagerTask: TODO when supported, need to call the same code that would handle the HandleStarted API
},
}, nil
}

func (h *handler) newActivityForStartExecution(
Comment thread
wenlong-gu marked this conversation as resolved.
mutableContext chasm.MutableContext,
request *workflowservice.StartActivityExecutionRequest,
namespaceID string,
maxCallbacks int,
) (*Activity, *eagerActivityTaskData, error) {
newActivity, err := NewStandaloneActivity(mutableContext, request)
if err != nil {
return nil, nil, err
}

if cbs := request.GetCompletionCallbacks(); len(cbs) > 0 {
if err := newActivity.addCompletionCallbacks(mutableContext, request.GetRequestId(), cbs, maxCallbacks); err != nil {
return nil, nil, err
}
}
if len(request.GetLinks()) > 0 {
if err := newActivity.attachLinks(mutableContext, request.GetLinks(), request.GetRequestId(), h.linkValidator, request.GetNamespace()); err != nil {
return nil, nil, err
}
}

if request.GetRequestEagerExecution() {
err = TransitionEagerStarted.Apply(newActivity, mutableContext, eagerStartEvent{
requestID: request.GetRequestId(),
identity: request.GetIdentity(),
})
} else {
err = TransitionScheduled.Apply(newActivity, mutableContext, nil)
}
if err != nil {
return nil, nil, err
}
if !request.GetRequestEagerExecution() {
return newActivity, nil, nil
}

eagerTaskData, err := newActivity.eagerActivityTaskData(mutableContext, eagerActivityTaskRequest{
namespaceID: namespaceID,
namespace: request.GetNamespace(),
requestID: request.GetRequestId(),
})
if err != nil {
return nil, nil, err
}
return newActivity, eagerTaskData, nil
Comment on lines +222 to +245

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit — The eager branch checks GetRequestEagerExecution() twice, and the guard in eagerActivityTaskData can never fail.

eagerActivityTaskData is called in the same transaction, right after TransitionEagerStarted succeeds. At that point the attempt is in progress, Count == 1, and StartRequestId == request.GetRequestId() by construction. So the guard at responses.go:54 is dead code. If it ever did fail, it would return nil, nil and silently drop the eager task. Once the guard is removed, eagerActivityTaskRequest.requestID has no other use.

Suggestion: Collapse the branching here, then drop the guard and the requestID field in responses.go.

Suggested change
if request.GetRequestEagerExecution() {
err = TransitionEagerStarted.Apply(newActivity, mutableContext, eagerStartEvent{
requestID: request.GetRequestId(),
identity: request.GetIdentity(),
})
} else {
err = TransitionScheduled.Apply(newActivity, mutableContext, nil)
}
if err != nil {
return nil, nil, err
}
if !request.GetRequestEagerExecution() {
return newActivity, nil, nil
}
eagerTaskData, err := newActivity.eagerActivityTaskData(mutableContext, eagerActivityTaskRequest{
namespaceID: namespaceID,
namespace: request.GetNamespace(),
requestID: request.GetRequestId(),
})
if err != nil {
return nil, nil, err
}
return newActivity, eagerTaskData, nil
if !request.GetRequestEagerExecution() {
return newActivity, nil, TransitionScheduled.Apply(newActivity, mutableContext, nil)
}
if err := TransitionEagerStarted.Apply(newActivity, mutableContext, eagerStartEvent{
requestID: request.GetRequestId(),
identity: request.GetIdentity(),
}); err != nil {
return nil, nil, err
}
return newActivity, newActivity.eagerActivityTaskData(mutableContext, namespaceID, request.GetNamespace()), nil

}

func businessIDPolicies(request *workflowservice.StartActivityExecutionRequest) (chasm.BusinessIDReusePolicy, chasm.BusinessIDConflictPolicy, error) {
reusePolicy, ok := businessIDReusePolicyMap[request.GetIdReusePolicy()]
if !ok {
return 0, 0, serviceerror.NewInvalidArgumentf("unsupported ID reuse policy: %v", request.GetIdReusePolicy())
}

conflictPolicy, ok := businessIDConflictPolicyMap[request.GetIdConflictPolicy()]
if !ok {
return 0, 0, serviceerror.NewInvalidArgumentf("unsupported ID conflict policy: %v", request.GetIdConflictPolicy())
}

return reusePolicy, conflictPolicy, nil
}

func (h *handler) grantEagerActivityDispatch(
Comment thread
wenlong-gu marked this conversation as resolved.
ctx context.Context,
namespaceID string,
request *workflowservice.StartActivityExecutionRequest,
) bool {
return common.GrantEagerDispatch(
ctx,
h.matchingClient,
namespaceID,
request.GetTaskQueue().GetName(),
request.GetPriority(),
nil,
)
}

// DescribeActivityExecution queries current activity state, optionally as a long-poll that waits
// for any state change. When used to long-poll, it returns an empty non-error response on context
// deadline expiry, to indicate that the state being waited for was not reached. Callers should
Expand Down
Loading
Loading