Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a75c5e4
Folded Nexus operation progress onto the next Workflow Task.
moetemp Oct 10, 2026
2ec1029
Merge branch 'moe/streams-n5-accept-progress' into moe/streams-n6-fol…
moetemp Oct 10, 2026
f557657
Kept Nexus progress off tasks forced to heartbeat a local activity.
moetemp Oct 10, 2026
631b156
Merged the API repin into N6.
moetemp Oct 10, 2026
c352e4f
Held Nexus progress off the retries of a heartbeat task too.
moetemp Oct 10, 2026
82e9f2a
Sent folded Nexus progress on one follow-up Workflow Task.
moetemp Oct 10, 2026
d06d85f
Merged the N5 review fixes into N6.
moetemp Oct 10, 2026
30c5a0a
Held Nexus progress off tasks a failover converts.
moetemp Oct 10, 2026
5aa9c53
Merged the API repin into N6.
moetemp Oct 10, 2026
5b7130f
Merged the N4 repin into N6.
moetemp Oct 10, 2026
e74503d
Merged the concurrent N4 repin merge into N6.
moetemp Oct 10, 2026
1683c2d
Merged the progress error mapping into N6.
moetemp Oct 10, 2026
e195621
Merged the N4 repin into N6.
moetemp Oct 10, 2026
4bd8d44
Merged the N5 review fixes into N6.
moetemp Oct 10, 2026
fcf27aa
Rate-limited the Workflow Tasks that Nexus operation progress schedules.
moetemp Oct 10, 2026
b413785
Skipped the Nexus progress check when nothing it depends on changed.
moetemp Oct 10, 2026
217a466
Checked where progress is after the task that carried it fails.
moetemp Oct 10, 2026
3208cdc
Wrapped the progress folding code at 100 columns.
moetemp Oct 10, 2026
2b5c36f
Merged the N4 repin into n6-fold-progress.
moetemp Oct 10, 2026
a1b90e6
Merged the N4 repin into n6-fold-progress.
moetemp Oct 12, 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
23 changes: 17 additions & 6 deletions api/persistence/v1/executions.pb.go

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

9 changes: 9 additions & 0 deletions chasm/lib/nexusoperation/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ one per operation onto the caller's next Workflow Task scheduled event. When fal
delivery is refused with a 400, which tells the handler to stop sending progress.`,
)

var ProgressMinInterval = dynamicconfig.NewNamespaceDurationSetting(
"nexusoperation.progressMinInterval",
time.Second,
`Experimental. The shortest time between a Workflow Task that carried Nexus operation progress
and the next task that progress schedules for the same caller Workflow. Progress that arrives
sooner waits, and newer progress folds into it. A task scheduled for any other reason still
carries it. This bounds the History a long stream costs the caller.`,
)

var ChasmWorkflowOperationsRolloutPercent = dynamicconfig.NewNamespaceIntSetting(
"nexusoperation.chasmWorkflowOperationsRolloutPercent",
0,
Expand Down
71 changes: 67 additions & 4 deletions chasm/lib/nexusoperation/operation.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,20 @@ type Operation struct {
// Callbacks holds completion callbacks to be invoked when this reaches a terminal state.
// Keyed by completionCallbackID(requestID, index).
Callbacks chasm.Map[string, *callback.Callback]

// PendingProgress is the latest progress no Workflow Task has carried yet.
PendingProgress chasm.Field[*nexuspb.NexusOperationProgress]
// DeliveredProgress is the latest progress a Workflow Task carried. A delivery whose
// counter is not above it is stale.
DeliveredProgress chasm.Field[*nexuspb.NexusOperationProgress]
}

// ProgressStore is implemented by a parent that delivers the operation's progress to its caller.
// A parent without it, such as none for a standalone operation, drops progress.
type ProgressStore interface {
// OnNexusOperationProgress records that the operation holds pending progress. The parent
// decides which Workflow Task takes it.
OnNexusOperationProgress(ctx chasm.MutableContext, operation *Operation) error
}

// NewOperation creates a new Operation component with the given persisted state.
Expand Down Expand Up @@ -298,10 +312,9 @@ func (o *Operation) HandleNexusCompletion(
return serviceerror.NewNotFound("operation not found")
}

// Progress never stands in for the start, and nothing folds it onto a Workflow Task yet, so it
// is accepted and dropped here.
if completion.GetProgress() != nil {
return nil
// Progress never stands in for the start, so it is handled before completion-before-start.
if progress := completion.GetProgress(); progress != nil {
return o.onProgress(ctx, progress)
}

links := completion.GetLinks()
Expand Down Expand Up @@ -329,6 +342,56 @@ func (o *Operation) HandleNexusCompletion(
}
}

// onProgress keeps the progress with the highest counter for the caller. Progress before the
// operation started is dropped, since it cannot stand in for the start response, and so is progress
// after it closed, since its completion supersedes it.
func (o *Operation) onProgress(
ctx chasm.MutableContext,
progress *nexuspb.NexusOperationProgress,
) error {
if o.Status != nexusoperationpb.OPERATION_STATUS_STARTED {
return nil
}
parent, ok := o.Store.TryGet(ctx)
if !ok {
return nil
}
store, ok := parent.(ProgressStore)
if !ok {
return nil
}
for _, seen := range []chasm.Field[*nexuspb.NexusOperationProgress]{
o.PendingProgress,
o.DeliveredProgress,
} {
if previous, ok := seen.TryGet(ctx); ok && previous.GetCounter() >= progress.GetCounter() {
return nil
}
}
o.PendingProgress = chasm.NewDataField(ctx, progress)
return store.OnNexusOperationProgress(ctx, o)
}

// TakePendingProgress returns the pending progress and records it as delivered.
func (o *Operation) TakePendingProgress(
ctx chasm.MutableContext,
) (*nexuspb.NexusOperationProgress, bool) {
progress, ok := o.PendingProgress.TryGet(ctx)
if !ok {
return nil, false
}
o.DeliveredProgress = chasm.NewDataField(ctx, progress)
o.PendingProgress = chasm.NewEmptyField[*nexuspb.NexusOperationProgress]()
return progress, true
}

// DropPendingProgress forgets the pending progress without delivering it.
func (o *Operation) DropPendingProgress(ctx chasm.MutableContext) {
if _, ok := o.PendingProgress.TryGet(ctx); ok {
o.PendingProgress = chasm.NewEmptyField[*nexuspb.NexusOperationProgress]()
}
}

// loadStartArgs is a ReadComponent callback that loads the start arguments from the operation.
func (o *Operation) loadStartArgs(
ctx chasm.Context,
Expand Down
140 changes: 140 additions & 0 deletions chasm/lib/nexusoperation/progress_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package nexusoperation

import (
"testing"

"github.com/stretchr/testify/require"
nexuspb "go.temporal.io/api/nexus/v1"
persistencespb "go.temporal.io/server/api/persistence/v1"
"go.temporal.io/server/chasm"
nexusoperationpb "go.temporal.io/server/chasm/lib/nexusoperation/gen/nexusoperationpb/v1"
)

// progressStoreComponent is a parent that takes progress.
type progressStoreComponent struct {
mockStoreComponent

pending int
}

func (s *progressStoreComponent) OnNexusOperationProgress(chasm.MutableContext, *Operation) error {
s.pending++
return nil
}

func TestHandleNexusProgress(t *testing.T) {
progress := func(counter int64) *persistencespb.ChasmNexusCompletion {
return &persistencespb.ChasmNexusCompletion{
Outcome: &persistencespb.ChasmNexusCompletion_Progress{
Progress: &nexuspb.NexusOperationProgress{Counter: counter},
},
}
}
newOp := func(status nexusoperationpb.OperationStatus, store OperationStore) *Operation {
op := newTestOperation()
op.Status = status
if store != nil {
op.Store = chasm.NewMockParentPtr(store)
}
return op
}
pendingCounter := func(ctx chasm.Context, op *Operation) int64 {
p, _ := op.PendingProgress.TryGet(ctx)
return p.GetCounter()
}
deliveredCounter := func(ctx chasm.Context, op *Operation) int64 {
p, _ := op.DeliveredProgress.TryGet(ctx)
return p.GetCounter()
}

for _, tc := range []struct {
name string
status nexusoperationpb.OperationStatus
store OperationStore
counters []int64
wantPending int64
}{
{
name: "HighestCounterWins",
status: nexusoperationpb.OPERATION_STATUS_STARTED,
store: &progressStoreComponent{},
counters: []int64{1, 3, 2},
wantPending: 3,
},
{
name: "DroppedBeforeStart",
status: nexusoperationpb.OPERATION_STATUS_SCHEDULED,
store: &progressStoreComponent{},
counters: []int64{1},
},
{
name: "DroppedAfterClose",
status: nexusoperationpb.OPERATION_STATUS_SUCCEEDED,
store: &progressStoreComponent{},
counters: []int64{1},
},
{
name: "DroppedWithoutCaller",
status: nexusoperationpb.OPERATION_STATUS_STARTED,
counters: []int64{1},
},
{
name: "DroppedByAParentThatTakesNoProgress",
status: nexusoperationpb.OPERATION_STATUS_STARTED,
store: &mockStoreComponent{},
counters: []int64{1},
},
} {
t.Run(tc.name, func(t *testing.T) {
ctx := &chasm.MockMutableContext{}
op := newOp(tc.status, tc.store)
for _, counter := range tc.counters {
require.NoError(t, op.HandleNexusCompletion(ctx, progress(counter)))
}
require.Equal(t, tc.wantPending, pendingCounter(ctx, op))
require.Equal(t, tc.status, op.Status, "progress never changes the operation status")
})
}

t.Run("StaleAfterDelivery", func(t *testing.T) {
ctx := &chasm.MockMutableContext{}
store := &progressStoreComponent{}
op := newOp(nexusoperationpb.OPERATION_STATUS_STARTED, store)
require.NoError(t, op.HandleNexusCompletion(ctx, progress(5)))
taken, ok := op.TakePendingProgress(ctx)
require.True(t, ok)
require.Equal(t, int64(5), taken.GetCounter())
require.Zero(t, pendingCounter(ctx, op))

require.NoError(t, op.HandleNexusCompletion(ctx, progress(4)))
require.Zero(t, pendingCounter(ctx, op), "a counter at or below the delivered one is stale")
require.NoError(t, op.HandleNexusCompletion(ctx, progress(6)))
require.Equal(t, int64(6), pendingCounter(ctx, op))
require.Equal(t, 2, store.pending)
})

t.Run("NewerProgressWaitsUntilATaskCarriesIt", func(t *testing.T) {
ctx := &chasm.MockMutableContext{}
store := &progressStoreComponent{}
op := newOp(nexusoperationpb.OPERATION_STATUS_STARTED, store)
require.NoError(t, op.HandleNexusCompletion(ctx, progress(1)))
_, ok := op.TakePendingProgress(ctx)
require.True(t, ok)

// Whether it folds into a task that has not started is the parent's call; the operation
// keeps it until a task carries it.
require.NoError(t, op.HandleNexusCompletion(ctx, progress(2)))
require.Equal(t, int64(2), pendingCounter(ctx, op))
require.Equal(t, int64(1), deliveredCounter(ctx, op))
require.Equal(t, 2, store.pending)
})

t.Run("DropPendingProgress", func(t *testing.T) {
ctx := &chasm.MockMutableContext{}
op := newOp(nexusoperationpb.OPERATION_STATUS_STARTED, &progressStoreComponent{})
require.NoError(t, op.HandleNexusCompletion(ctx, progress(1)))
op.DropPendingProgress(ctx)
require.Zero(t, pendingCounter(ctx, op))
require.Zero(t, deliveredCounter(ctx, op), "dropped progress is not delivered")
})
}
37 changes: 37 additions & 0 deletions chasm/lib/workflow/gen/workflowpb/v1/state.go-helpers.pb.go

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

Loading