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

#### Status

November 27, 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)
userActivityChan := workflow.GetSignalChannel(ctx, "user_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(userActivityChan, 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 {
Copy link
Member

Choose a reason for hiding this comment

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

I like this example! Nice!

// Start with 5 minute timeout
timeout := 5 * time.Minute
timer := resettabletimer.New(ctx, timeout)

userActivityChan := 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(userActivityChan, 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
// Timer is the concrete implementation of a resettable timer
type Timer struct {
Future workflow.Future // Use this with workflow.Selector
// contains unexported fields
}

// ResettableTimer is the interface that Timer implements
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) *Timer
```

##### Usage

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

// Access the underlying Future for use with workflow.Selector
selector.AddFuture(timer.Future, func(f workflow.Future) {
// timer fired
})

// Get blocks until the timer fires
err := timer.Future.Get(ctx, nil)

// IsReady returns true if the timer has fired
if timer.Future.IsReady() {
// timer has fired
}
```

#### 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()

// Simulate a signal being sent after 10 seconds (e.g., user interaction)
// This would reset the timer in the workflow, preventing timeout
env.RegisterDelayedCallback(func() {
env.SignalWorkflow("user_activity", "click")
}, 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
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