Skip to content
Open
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
225 changes: 225 additions & 0 deletions x/resettabletimer/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
### Resettable Timers in Cadence Workflows

#### Status

November 4, 2025

This is experimental and the API may change in future releases.

#### Background

In Cadence workflows, timers are a fundamental building block for implementing timeouts and delays. However, standard timers cannot be reset once created - you must cancel the old timer and create a new one, which can lead to complex code patterns.

The resettable timer provides a simple way to implement timeout patterns that need to restart based on external events.

#### Getting Started

Import the package:

```go
import (
"go.uber.org/cadence/workflow"
"go.uber.org/cadence/x/resettabletimer"
)
```

#### Basic Usage

Create a timer that can be reset:

```go
func MyWorkflow(ctx workflow.Context) error {
// Create a timer that fires after 30 seconds
timer := resettabletimer.New(ctx, 30*time.Second)

// Wait for the timer
err := timer.Future.Get(ctx, nil)
if err != nil {
return err
}

// Timer fired - handle timeout
workflow.GetLogger(ctx).Info("Timeout occurred")
return nil
}
```

#### Resetting the Timer

```go
func MyWorkflow(ctx workflow.Context) error {
timer := resettabletimer.New(ctx, 30*time.Second)
activityChan := workflow.GetSignalChannel(ctx, "activity")

selector := workflow.NewSelector(ctx)

// Add timer to selector
selector.AddFuture(timer.Future, func(f workflow.Future) {
workflow.GetLogger(ctx).Info("User inactive for 30 seconds")
})

// Add signal channel to selector
selector.AddReceive(activityChan, func(c workflow.Channel, more bool) {
var signal string
c.Receive(ctx, &signal)

// Reset the timer when activity is detected
timer.Reset(30 * time.Second)
workflow.GetLogger(ctx).Info("Activity detected, timer reset")
})

selector.Select(ctx)
return nil
}
```

#### Example: Inactivity Timeout with Dynamic Duration

```go
func InactivityTimeoutWorkflow(ctx workflow.Context) error {
// Start with 5 minute timeout
timeout := 5 * time.Minute
timer := resettabletimer.New(ctx, timeout)

activityChan := workflow.GetSignalChannel(ctx, "user_activity")
stopChan := workflow.GetSignalChannel(ctx, "stop")

done := false
for !done {
selector := workflow.NewSelector(ctx)

selector.AddFuture(timer.Future, func(f workflow.Future) {
workflow.GetLogger(ctx).Info("User inactive - logging out")
done = true
})

selector.AddReceive(activityChan, func(c workflow.Channel, more bool) {
var activity struct {
Type string
Timeout time.Duration
}
c.Receive(ctx, &activity)

// Reset with possibly different duration
if activity.Timeout > 0 {
timeout = activity.Timeout
}
timer.Reset(timeout)

workflow.GetLogger(ctx).Info("Activity detected",
"type", activity.Type,
"new_timeout", timeout)
})

selector.AddReceive(stopChan, func(c workflow.Channel, more bool) {
var stop bool
c.Receive(ctx, &stop)
done = true
})

selector.Select(ctx)
}

return nil
}
```

#### API Reference

##### Types

```go
type ResettableTimer interface {
workflow.Future

// Reset cancels the current timer and starts a new one with the given duration.
// If the timer has already fired, Reset has no effect.
Reset(d time.Duration)
}
```

##### Functions

```go
// New creates a new resettable timer that fires after duration d.
func New(ctx workflow.Context, d time.Duration) *ResettableTimer
```

##### Methods

```go
// Reset cancels the current timer and starts a new one with the given duration
timer.Reset(newDuration time.Duration)

// Future is the underlying Future field for use with workflow.Selector
timer.Future workflow.Future

// Get blocks until the timer fires (convenience method)
timer.Future.Get(ctx, nil)

// IsReady returns true if the timer has fired (convenience method)
timer.Future.IsReady()
```

#### Important Notes

1. **Use with Selector**: When using the timer with `workflow.Selector`, you access the Future field directly:
```go
selector.AddFuture(timer.Future, func(f workflow.Future) {
// timer fired
})
```

2. **Reset After Fire**: Once a timer has fired, calling `Reset()` has no effect. The timer is considered "done" after it fires.

3. **Determinism**: Like all workflow code, timer operations are deterministic and will replay correctly during workflow replay.

4. **Resolution**: Timer resolution is in seconds using `math.Ceil(d.Seconds())`, consistent with standard Cadence timers.

#### Testing

The resettable timer works seamlessly with Cadence's workflow test suite:

```go
func TestMyWorkflow(t *testing.T) {
testSuite := &testsuite.WorkflowTestSuite{}
env := testSuite.NewTestWorkflowEnvironment()

// Register delayed callback to simulate activity
env.RegisterDelayedCallback(func() {
env.SignalWorkflow("activity", "user_action")
}, 10*time.Second)

env.ExecuteWorkflow(MyWorkflow)

require.True(t, env.IsWorkflowCompleted())
require.NoError(t, env.GetWorkflowError())
}
```

#### Comparison with Standard Timers

**Standard Timer Pattern:**
```go
// Must manage timer cancellation and recreation manually
var timerCancel workflow.CancelFunc
timerCtx, timerCancel := workflow.WithCancel(ctx)
timer := workflow.NewTimer(timerCtx, 30*time.Second)

// On activity - must cancel and recreate
timerCancel()
timerCtx, timerCancel = workflow.WithCancel(ctx)
timer = workflow.NewTimer(timerCtx, 30*time.Second)
```

**Resettable Timer Pattern:**
```go
// Simple creation and reset
timer := resettabletimer.New(ctx, 30*time.Second)

// On activity - just reset
timer.Reset(30 * time.Second)
```

The resettable timer encapsulates the cancellation and recreation logic, making timeout patterns much cleaner and easier to reason about.

74 changes: 74 additions & 0 deletions x/resettabletimer/resettable_timer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package resettabletimer

import (
"time"

"go.uber.org/cadence"
"go.uber.org/cadence/workflow"
)

type (
// ResettableTimer represents a timer that can be reset to restart its countdown.
ResettableTimer interface {
workflow.Future

// Reset - Cancels the current timer and starts a new one with the given duration.
// If the timer has already fired, Reset has no effect.
Reset(d time.Duration)
}

Timer struct {
ctx workflow.Context
timerCtx workflow.Context
cancelTimer workflow.CancelFunc
// This is suboptimal, but we cannot implement the internal asyncFuture interface because it is not exported. It is what it is.
Future workflow.Future
settable workflow.Settable
duration time.Duration
isReady bool
}
)

// New returns a timer that can be reset to restart its countdown. The timer becomes ready after the
// specified duration d. The timer can be reset using timer.Reset(duration) with a new duration. This is useful for
// implementing timeout patterns that should restart based on external events. The workflow needs to use this
// New() instead of creating new timers repeatedly. The current timer resolution implementation is in
// seconds and uses math.Ceil(d.Seconds()) as the duration. But is subjected to change in the future.
func New(ctx workflow.Context, d time.Duration) *Timer {
rt := &Timer{
ctx: ctx,
duration: d,
}
rt.Future, rt.settable = workflow.NewFuture(ctx)
rt.startTimer(d)
return rt
}

func (rt *Timer) startTimer(d time.Duration) {
rt.duration = d

if rt.cancelTimer != nil {
rt.cancelTimer()
}

rt.timerCtx, rt.cancelTimer = workflow.WithCancel(rt.ctx)

timer := workflow.NewTimer(rt.timerCtx, d)

workflow.Go(rt.ctx, func(ctx workflow.Context) {
err := timer.Get(ctx, nil)

if !cadence.IsCanceledError(err) && !rt.isReady {
rt.isReady = true
rt.settable.Set(nil, err)
}
})
}

// Reset - Cancels the current timer and starts a new one with the given duration.
// If the timer has already fired, Reset has no effect.
func (rt *Timer) Reset(d time.Duration) {
if !rt.isReady {
rt.startTimer(d)
}
}
Loading