Skip to content
Merged
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
85 changes: 69 additions & 16 deletions chasm/lib/activity/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"fmt"
"math/rand"
"slices"
"strings"
"time"

"github.com/nexus-rpc/sdk-go/nexus"
Expand Down Expand Up @@ -639,13 +640,14 @@ func (a *Activity) UpdateActivityExecutionOptions(
}

frontendReq := req.GetFrontendRequest()

// start_delay updates are only valid while the activity is still in its delay window.
var hasStartDelayInMask bool
updateFields := map[string]struct{}{}
if mask := frontendReq.GetUpdateMask(); mask != nil {
_, hasStartDelayInMask = util.ParseFieldMask(mask)["startDelay"]
updateFields = util.ParseFieldMask(mask)
}
if !frontendReq.GetRestoreOriginal() && hasStartDelayInMask {

// start_delay updates are only valid while the activity is still in its delay window.
_, hasStartDelayInMask := updateFields["startDelay"]
if hasStartDelayInMask {
newDelay := frontendReq.GetActivityOptions().GetStartDelay()
if err := validateStartDelay(newDelay); err != nil {
return nil, err
Expand All @@ -663,6 +665,9 @@ func (a *Activity) UpdateActivityExecutionOptions(
}
}

attempt := a.LastAttempt.Get(ctx)
policyRetryIntervalBeforeUpdate := backoff.CalculateExponentialRetryInterval(a.RetryPolicy, attempt.GetCount()-1)

if frontendReq.GetRestoreOriginal() {
ogOptions := a.GetOriginalOptions()
a.TaskQueue = common.CloneProto(ogOptions.GetTaskQueue())
Expand All @@ -683,15 +688,14 @@ func (a *Activity) UpdateActivityExecutionOptions(
}
}

attempt := a.LastAttempt.Get(ctx)

// Recalculate the retry interval based on the (possibly updated) retry policy.
// This ensures a shortened retry interval takes effect immediately on re-dispatch.
status := a.GetStatus()
if (status == activitypb.ACTIVITY_EXECUTION_STATUS_SCHEDULED ||
status == activitypb.ACTIVITY_EXECUTION_STATUS_PAUSED) &&
attempt.GetCurrentRetryInterval() != nil {

// Recalculate policy-derived retry intervals based on the (possibly updated) retry policy.
// Worker-provided NextRetryDelay values are preserved for their already-scheduled retry.
if a.shouldRecalculateCurrentRetryInterval(
attempt,
frontendReq.GetRestoreOriginal(),
updateFields,
policyRetryIntervalBeforeUpdate,
) {
newInterval := backoff.CalculateExponentialRetryInterval(a.RetryPolicy, attempt.GetCount()-1)
attempt.CurrentRetryInterval = durationpb.New(newInterval)
}
Expand Down Expand Up @@ -728,6 +732,52 @@ func (a *Activity) UpdateActivityExecutionOptions(
}, nil
}

// shouldRecalculateCurrentRetryInterval reports whether the pending retry's CurrentRetryInterval
// should be re-derived from the (possibly updated) retry policy. All of the following must hold:
//
// - the activity is waiting to retry, i.e. in SCHEDULED or PAUSED state (a running attempt has no
// pending backoff to recalculate);
// - a retry is actually pending (CurrentRetryInterval is set);
// - the update touches the retry policy: either RestoreOriginal (which replaces the whole policy),
// or the update mask includes "retryPolicy" or one of its subfields;
// - the pending interval is still policy-derived, i.e. it equals the pre-update policy value.
// A mismatch means the interval is a worker-provided NextRetryDelay override, which we preserve.
func (a *Activity) shouldRecalculateCurrentRetryInterval(

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.

doc comment for the logic here would be helpful

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.

as I understand it we recalc if all of the following are true:

  • activity is not scheduled or paused
  • the update included some change to retryPolicy, either the whole policy or at least one subfield
  • AND there isn't a worker provided NextRetryDelay
    Is that correct?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added docs.
Mostly, with corrections:

  • For state, it's the opposit. We recalc only when the activity is in SCHEDULED or PAUSED (i.e. waiting to retry, so there's a pending backoff to adjust). A running attempt has no pending retry interval to recalculate.
  • the "update touched retryPolicy" check only applies to normal mask updates. On RestoreOriginal we skip that check entirely, since restore swaps in the whole original policy and should recalc regardless of the mask.

attempt *activitypb.ActivityAttemptState,
restoreOriginal bool,
updateFields map[string]struct{},
policyRetryIntervalBeforeUpdate time.Duration,
) bool {
status := a.GetStatus()
if status != activitypb.ACTIVITY_EXECUTION_STATUS_SCHEDULED &&
status != activitypb.ACTIVITY_EXECUTION_STATUS_PAUSED {
return false
}

currentRetryInterval := attempt.GetCurrentRetryInterval()
if currentRetryInterval == nil {
return false
}

if !restoreOriginal {
hasRetryPolicyInMask := false
for field := range updateFields {

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.

Suggested change
for field := range updateFields {
if _, ok := updateFields["retryPolicy"]; ok {
hasRetryPolicyInMask = true
} else {
for field := range updateFields {
if strings.HasPrefix(field, "retryPolicy.") {
hasRetryPolicyInMask = true
break
}
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

If you're not strongly opinonated on this, I'd like to keep what I have as I feel it's shorter and more readable

if field == "retryPolicy" || strings.HasPrefix(field, "retryPolicy.") {
hasRetryPolicyInMask = true
break
}
}
if !hasRetryPolicyInMask {
return false
}
}

// CurrentRetryInterval stores either policy-derived backoff or worker-provided NextRetryDelay overrides.
// Only recalculate intervals that match the old policy value; different values are treated as explicit
// worker overrides.
return currentRetryInterval.AsDuration() == policyRetryIntervalBeforeUpdate

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.

Can you send consecutive updates and recompute the retry interval on the second one? The currentRetryInterval here is just the last computed one I think, not the original retry interval from before a potential first retry interval created.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point out. Consecutive updates do recompute correctly. policyRetryIntervalBeforeUpdate is recomputed each call from the current a.RetryPolicy, and each update persists both the new policy and the recalculated interval, so they stay in sync. The second update compares against update 1's value (not the original) and recalculates as expected. I added a test to cover this case

}

// mergeActivityOptions applies the field mask from the request to the activity state.
// The structure mirrors the field-mask logic in service/history/api/updateactivityoptions/api.go
func (a *Activity) mergeActivityOptions(
Expand Down Expand Up @@ -897,6 +947,8 @@ func (a *Activity) handleUnpauseRequested(ctx chasm.MutableContext, req *activit
if err := TransitionUnpausedWhilePauseRequested.Apply(a, ctx, event); err != nil {
return nil, err
}
case activitypb.ACTIVITY_EXECUTION_STATUS_RESET_REQUESTED:
a.ResetKeepPaused = false
default:
return nil, serviceerror.NewFailedPreconditionf("activity is in non-unpausable state %v", a.GetStatus())
}
Expand All @@ -911,6 +963,8 @@ func (a *Activity) isPaused() bool {
case activitypb.ACTIVITY_EXECUTION_STATUS_PAUSED,
activitypb.ACTIVITY_EXECUTION_STATUS_PAUSE_REQUESTED:
return true
case activitypb.ACTIVITY_EXECUTION_STATUS_RESET_REQUESTED:
return a.GetResetKeepPaused()
default:
return false
}
Expand All @@ -921,17 +975,16 @@ func (a *Activity) unpause(
event unpauseEvent,
) {
attempt := a.LastAttempt.Get(ctx)
// Compute the dispatch time while the pending retry state is still intact; it is cleared below.
dispatchTime := a.unpauseDispatchTime(ctx, event)

if event.req.GetResetAttempts() {
attempt.Count = 1
attempt.CurrentRetryInterval = nil
}
if event.req.GetResetHeartbeat() {
a.LastHeartbeat = chasm.NewDataField(ctx, &activitypb.ActivityHeartbeatState{})
}
attempt.Stamp++
attempt.CurrentRetryInterval = nil
if timeout := a.GetScheduleToStartTimeout().AsDuration(); timeout > 0 {
ctx.AddTask(
a,
Expand Down
3 changes: 1 addition & 2 deletions chasm/lib/activity/activity_tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,7 @@ func (h *scheduleToCloseTimeoutTaskHandler) Validate(
}
// Stamp check: discard tasks from before the most recent ScheduleToCloseTimeoutTask was
// scheduled (e.g. after a schedule-to-close extension or a disable+re-enable cycle).
// Tasks without a stamp (stamp=0) predate this field and are not validated by stamp.
if task.GetStamp() != 0 && task.GetStamp() != activity.GetScheduleToCloseStamp() {
if task.GetStamp() != activity.GetScheduleToCloseStamp() {
return false, nil
}
return true, nil
Expand Down
80 changes: 80 additions & 0 deletions chasm/lib/activity/activity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -643,3 +643,83 @@ func TestEffectiveUserMetadata_FallsBackToLegacy(t *testing.T) {
got := activity.effectiveUserMetadata(ctx)
require.Same(t, legacyMD, got)
}

func TestShouldRecalculateCurrentRetryInterval(t *testing.T) {
policyRetryInterval := 2 * time.Second

testCases := []struct {
name string
status activitypb.ActivityExecutionStatus
restoreOriginal bool
updateFields map[string]struct{}
currentRetryInterval *durationpb.Duration
expectRecalculate bool
}{
{
name: "retry policy subfield update with policy-derived interval",
status: activitypb.ACTIVITY_EXECUTION_STATUS_SCHEDULED,
updateFields: map[string]struct{}{"retryPolicy.initialInterval": {}},
currentRetryInterval: durationpb.New(policyRetryInterval),
expectRecalculate: true,
},
{
name: "retry policy replacement with policy-derived interval",
status: activitypb.ACTIVITY_EXECUTION_STATUS_SCHEDULED,
updateFields: map[string]struct{}{"retryPolicy": {}},
currentRetryInterval: durationpb.New(policyRetryInterval),
expectRecalculate: true,
},
{
name: "restore original with policy-derived interval",
status: activitypb.ACTIVITY_EXECUTION_STATUS_PAUSED,
restoreOriginal: true,
currentRetryInterval: durationpb.New(policyRetryInterval),
expectRecalculate: true,
},
{
name: "unrelated update preserves retry interval",
status: activitypb.ACTIVITY_EXECUTION_STATUS_SCHEDULED,
updateFields: map[string]struct{}{"heartbeatTimeout": {}},
currentRetryInterval: durationpb.New(policyRetryInterval),
},
{
name: "worker override differs from policy interval",
status: activitypb.ACTIVITY_EXECUTION_STATUS_SCHEDULED,
updateFields: map[string]struct{}{"retryPolicy.initialInterval": {}},
currentRetryInterval: durationpb.New(policyRetryInterval + time.Second),
},
{
name: "started activity is not in retry backoff",
status: activitypb.ACTIVITY_EXECUTION_STATUS_STARTED,
updateFields: map[string]struct{}{"retryPolicy.initialInterval": {}},
currentRetryInterval: durationpb.New(policyRetryInterval),
},
{
name: "missing retry interval",
status: activitypb.ACTIVITY_EXECUTION_STATUS_SCHEDULED,
updateFields: map[string]struct{}{"retryPolicy.initialInterval": {}},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
activity := &Activity{
ActivityState: &activitypb.ActivityState{
Status: tc.status,
},
}
attempt := &activitypb.ActivityAttemptState{
CurrentRetryInterval: tc.currentRetryInterval,
}

got := activity.shouldRecalculateCurrentRetryInterval(
attempt,
tc.restoreOriginal,
tc.updateFields,
policyRetryInterval,
)

require.Equal(t, tc.expectRecalculate, got)
})
}
}
2 changes: 1 addition & 1 deletion chasm/lib/activity/gen/activitypb/v1/tasks.pb.go

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

2 changes: 1 addition & 1 deletion chasm/lib/activity/proto/v1/tasks.proto
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ message ScheduleToStartTimeoutTask {
message ScheduleToCloseTimeoutTask {
// The schedule-to-close stamp for this task. Used for task validation.
// See also [ActivityState.schedule_to_close_stamp].
// Tasks without a stamp (stamp=0) predate this field and are not validated by stamp.
// Tasks without a stamp (stamp=0) predate this field and are valid only while the activity stamp is also 0.
int32 stamp = 1;
}

Expand Down
Loading
Loading