Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ require (
github.com/nexus-rpc/sdk-go v0.3.0
github.com/robfig/cron v1.2.0
github.com/stretchr/testify v1.10.0
go.temporal.io/api v1.53.0
go.temporal.io/api v1.55.1-0.20251017163919-d527807d0d4e
golang.org/x/sync v0.13.0
golang.org/x/sys v0.32.0
golang.org/x/time v0.3.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.temporal.io/api v1.53.0 h1:6vAFpXaC584AIELa6pONV56MTpkm4Ha7gPWL2acNAjo=
go.temporal.io/api v1.53.0/go.mod h1:iaxoP/9OXMJcQkETTECfwYq4cw/bj4nwov8b3ZLVnXM=
go.temporal.io/api v1.55.1-0.20251017163919-d527807d0d4e h1:8Y8p3tBzobAYO3KjuoiGXjDxeKlPsrEjjDRfLlB7O7c=
go.temporal.io/api v1.55.1-0.20251017163919-d527807d0d4e/go.mod h1:iaxoP/9OXMJcQkETTECfwYq4cw/bj4nwov8b3ZLVnXM=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
Expand Down
3 changes: 3 additions & 0 deletions internal/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ const (

// QueryTypeWorkflowMetadata is the query name for the workflow metadata.
QueryTypeWorkflowMetadata string = "__temporal_workflow_metadata"

// SignalTypeSuggestContinueAsNew is the signal name to set ContinueAsNewSuggested=true in the workflow env.
SignalTypeSuggestContinueAsNew = "__suggest_continue_as_new"
Copy link
Member

@cretz cretz Oct 17, 2025

Choose a reason for hiding this comment

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

Is this a proposal to change every SDK to accept a special built-in signal name to suggest continue as new? I would recommend keeping the one canonical place to get suggest-continue-as-new, in the task event. And if we need an RPC call to have the server apply this manually instead of how it does today, we can add it I think. Is the whole reason to use a signal because 1) we're concerned we can't send an empty task, and 2) we're unwilling to make a continue-as-new-suggested event?

If we want to take an approach like this, I think we can make continue as new suggested something that can be updated via UpdateWorkflowExecutionOptions (or separate RPC) and use WorkflowPropertiesModifiedExternallyEventAttributes or WorkflowExecutionOptionsUpdatedEventAttributes to relay the update to the SDK.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hey Chad, my intention was not for this to be ready for review as an actual ready-to-merge PR (it doesn't even have tests or a server implementation for the API change yet). I'm working on a blueprint for the PINNED_UNTIL_CONTINUE_AS_NEW behavior / "Trampolining" feature and wanted to use this PR diff as a way to communicate my initial ideas for how it could be implemented in the SDKs.

I don't intend to skip cross-SDK design for this at all. In fact, this PR is still in the design phase. Sketching it out in Go felt like the most straightforward way to get a sense of whether the implementation was technically viable, since I'm not fluent enough in any of our other SDKs to sketch it there.

I thought marking it as DRAFT/PROPOSAL while I worked on some other things would sufficiently communicate that it wasn't ready for review, but next time I'll be more explicit in the PR description as well. Keep an eye out for an incoming https://github.com/temporalio/features/issues and request for comment on my blueprint!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The reason to use a signal is to provide an API through which inactive workflows can be woken up so that they have the opportunity to continue-as-new onto a new worker version even if they are waiting on a timer or other condition.

Ideally this suggestion signal / empty task / API could be exposed to users so that they can apply this suggestion to workflows when the user's specific validation requirements are met for the new version if they choose.

Signal seems like a straightforward way to do that, but WorkflowExecutionOptionsUpdatedEventAttributes + scheduling an empty task could totally do the same thing, and I think is a great option that I hadn't thought of!

)

type (
Expand Down
29 changes: 29 additions & 0 deletions internal/internal_event_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -1199,6 +1199,9 @@ func (weh *workflowExecutionEventHandlerImpl) ProcessEvent(
// Update workflow info fields
weh.workflowInfo.currentHistoryLength = int(event.EventId)
weh.workflowInfo.continueAsNewSuggested = event.GetWorkflowTaskStartedEventAttributes().GetSuggestContinueAsNew()
weh.workflowInfo.continueAsNewSuggestedReason = convertContinueAsNewSuggestedReason(
event.GetWorkflowTaskStartedEventAttributes().GetContinueAsNewSuggestedReason(),
)
weh.workflowInfo.currentHistorySize = int(event.GetWorkflowTaskStartedEventAttributes().GetHistorySizeBytes())
// Reset the counter on command helper used for generating ID for commands
weh.commandsHelper.setCurrentWorkflowTaskStartedEventID(event.GetEventId())
Expand Down Expand Up @@ -1751,9 +1754,35 @@ func (weh *workflowExecutionEventHandlerImpl) ProcessLocalActivityResult(lar *lo
func (weh *workflowExecutionEventHandlerImpl) handleWorkflowExecutionSignaled(
attributes *historypb.WorkflowExecutionSignaledEventAttributes,
) error {
switch attributes.GetSignalName() {
case SignalTypeSuggestContinueAsNew:
weh.workflowInfo.continueAsNewSuggested = true
var reason enumspb.ContinueAsNewSuggestedReason
err := weh.dataConverter.FromPayload(attributes.GetInput().GetPayloads()[0], &reason)
Copy link

Choose a reason for hiding this comment

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

Bug: Signal Handling Accesses Unchecked Payload

In handleWorkflowExecutionSignaled, processing SignalTypeSuggestContinueAsNew directly accesses attributes.GetInput().GetPayloads()[0]. This is unsafe as it doesn't validate if the input or payloads slice exists or has elements, potentially causing a runtime panic if the signal lacks expected payload data.

Fix in Cursor Fix in Web

if err != nil {
return fmt.Errorf("unable to convert continue-as-new suggested reason: %w", err)
}
weh.workflowInfo.continueAsNewSuggestedReason = convertContinueAsNewSuggestedReason(reason)
}
return weh.signalHandler(attributes.GetSignalName(), attributes.Input, attributes.Header)
}

// TODO(carlydf): move this helper somewhere appropriate
func convertContinueAsNewSuggestedReason(reason enumspb.ContinueAsNewSuggestedReason) ContinueAsNewSuggestedReason {
switch reason {
case enumspb.CONTINUE_AS_NEW_SUGGESTED_REASON_UNSPECIFIED:
return ContinueAsNewSuggestedReasonUnspecified
case enumspb.CONTINUE_AS_NEW_SUGGESTED_REASON_UPDATE_REGISTRY_TOO_LARGE:
return ContinueAsNewSuggestedReasonUpdateRegistryTooLarge
case enumspb.CONTINUE_AS_NEW_SUGGESTED_REASON_HISTORY_SIZE_TOO_LARGE:
return ContinueAsNewSuggestedReasonHistorySizeTooLarge
case enumspb.CONTINUE_AS_NEW_SUGGESTED_REASON_WORKER_DEPLOYMENT_VERSION_CHANGED:
return ContinueAsNewSuggestedReasonWorkerDeploymentVersionChanged
default:
return ContinueAsNewSuggestedReasonUnspecified
}
}

func (weh *workflowExecutionEventHandlerImpl) handleStartChildWorkflowExecutionFailed(event *historypb.HistoryEvent) error {
attributes := event.GetStartChildWorkflowExecutionFailedEventAttributes()
childWorkflowID := attributes.GetWorkflowId()
Expand Down
46 changes: 43 additions & 3 deletions internal/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,39 @@ const (
VersioningBehaviorAutoUpgrade
)

// ContinueAsNewSuggestedReason specifies why ContinueAsNewSuggested is true.
//
// NOTE: Experimental
//
// Exposed as: [go.temporal.io/sdk/workflow.ContinueAsNewSuggestedReason]
type ContinueAsNewSuggestedReason int

const (
// ContinueAsNewSuggestedReasonUnspecified - ContinueAsNewSuggestedReason unknown.
// Reason will be Unspecified if the server is not configured to suggest continue as new,
// if the server is not configured to provide a reason for continue as new suggested, or
// if continue as new is not suggested.
//
// Exposed as: [go.temporal.io/sdk/workflow.ContinueAsNewSuggestedReasonUnspecified]
ContinueAsNewSuggestedReasonUnspecified ContinueAsNewSuggestedReason = iota

// ContinueAsNewSuggestedReasonHistorySizeTooLarge - Workflow History size is getting too large.
//
// Exposed as: [go.temporal.io/sdk/workflow.ContinueAsNewSuggestedReasonHistorySizeTooLarge]
ContinueAsNewSuggestedReasonHistorySizeTooLarge

// ContinueAsNewSuggestedReasonUpdateRegistryTooLarge - Workflow Update registry is getting too large.
//
// Exposed as: [go.temporal.io/sdk/workflow.ContinueAsNewSuggestedReasonUpdateRegistryTooLarge]
ContinueAsNewSuggestedReasonUpdateRegistryTooLarge

// ContinueAsNewSuggestedReasonWorkerDeploymentVersionChanged - Workflow's Worker Deployment has a new Ramping
// Version or Current Version, and the workflow's Versioning Behavior is PinnedUntilContinueAsNew.
//
// Exposed as: [go.temporal.io/sdk/workflow.ContinueAsNewSuggestedReasonWorkerDeploymentVersionChanged]
ContinueAsNewSuggestedReasonWorkerDeploymentVersionChanged
)

// NexusOperationCancellationType specifies what action should be taken for a Nexus operation when the
// caller is cancelled.
//
Expand Down Expand Up @@ -1350,9 +1383,10 @@ type WorkflowInfo struct {
// this worker
currentTaskBuildID string

continueAsNewSuggested bool
currentHistorySize int
currentHistoryLength int
continueAsNewSuggested bool
continueAsNewSuggestedReason ContinueAsNewSuggestedReason
currentHistorySize int
currentHistoryLength int
// currentRunID is the current run ID of the workflow task, deterministic over reset
currentRunID string
}
Expand Down Expand Up @@ -1404,6 +1438,12 @@ func (wInfo *WorkflowInfo) GetContinueAsNewSuggested() bool {
return wInfo.continueAsNewSuggested
}

// GetContinueAsNewSuggestedReason returns the reason for ContinueAsNewSuggested if one exists.
// This value may change throughout the life of the workflow.
func (wInfo *WorkflowInfo) GetContinueAsNewSuggestedReason() ContinueAsNewSuggestedReason {
return wInfo.continueAsNewSuggestedReason
}

// GetWorkflowInfo extracts info of a current workflow from a context.
//
// Exposed as: [go.temporal.io/sdk/workflow.GetInfo]
Expand Down
5 changes: 5 additions & 0 deletions workflow/deterministic_wrappers.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ type (
//
// NOTE: Experimental
AwaitOptions = internal.AwaitOptions

// ContinueAsNewSuggestedReason is the reason for ContinueAsNewSuggested
//
// NOTE: Experimental
ContinueAsNewSuggestedReason = internal.ContinueAsNewSuggestedReason
)

// Await blocks the calling thread until condition() returns true.
Expand Down