Files
tasks/chasm/lib/scheduler/scheduler_test.go
David PorterandGitHub 01eab8d0e8 feat: Adds next-action-time for schedules (#10390)
## What changed?

This adds a search attributes to schedulesV2:
`TemporalScheduleNextActionTimeTime`

- `TemporalScheduleNextActiontimeTime` Denotes when the next expected
firing of the schedule should be. Ie, if a schedule fires every 5
minutes and it fired a minute ago, it should be 4 minutes in the future.

## Why?

The intent is to add some broadly useful search values to visibility,
both for customers, but also for operators who are interested in
monitoring the behaviour of SchedulesV2. Because we're expecting to move
to CHASM based scheduling hereafter, I've not attempted to backport
these values to the V1 scheduler.

## How did you test it?
- [ ] built
- [ ] run locally and tested manually
- [X] covered by existing tests
- [X] added new unit test(s)
- [X] added new functional test(s)

Local tests: Verified that the schema is present

Elasticsearch Upgrade path:
```
./temporal-elasticsearch-tool -ep http://127.0.0.1:9200 \
      update-schema --index temporal_visibility_v1_dev

2026-05-27T13:27:09.829-0700	INFO	Starting template upgrade	{"config": {"TemplateContent":"{\n  ...
2026-05-27T13:27:09.962-0700	INFO	Index mappings updated successfully	{"indexName": "temporal_visibility_v1_dev", "logging-call-at": "/Users/dp/dev/t/temporal/tools/elasticsearch/tasks.go:198"}
2026-05-27T13:27:09.962-0700	INFO	Index mapping update complete	{"logging-call-at": "/Users/dp/dev/t/temporal/tools/elasticsearch/tasks.go:148"}

$ curl -s "http://localhost:9200/temporal_visibility_v1_dev/_mapping" \
    | jq '.temporal_visibility_v1_dev.mappings.properties.TemporalScheduleNextActionTime'
{
  "type": "date_nanos"
}

```
Postgres:
```
temporal_visibility=#
  INSERT INTO executions_visibility
      (namespace_id, run_id, workflow_id, start_time, execution_time, status,
       workflow_type_name, encoding, search_attributes)
  VALUES (
      '00000000-0000-0000-0000-000000000001'::uuid, 'r1', 'w1',
      NOW(), NOW(), 1, 'test',
      'Proto3',
      '{"TemporalScheduleNextActionTime": "2030-01-02T03:04:05Z"}'::jsonb
  );
INSERT 0 1

temporal_visibility=# select temporalschedulenextactiontime from executions_visibility;
 temporalschedulenextactiontime
--------------------------------
 2030-01-02 03:04:05
(1 row)
```

mysql:
```
mysql> INSERT INTO executions_visibility       (namespace_id, run_id, workflow_id, start_time, execution_time, status,        workflow_type_name, encoding, search_attributes)   VALUES (       '00000000-0000-0000-0000-000000000001', 'r1', 'w1',       NOW(6), NOW(6), 1, 'test', 'Proto3',       '{"TemporalScheduleNextActionTime": "2030-01-02T03:04:05Z"}'   );
Query OK, 1 row affected (0.01 sec)

mysql> SELECT TemporalScheduleNextActionTime
    ->   FROM executions_visibility WHERE run_id = 'r1';
+--------------------------------+
| TemporalScheduleNextActionTime |
+--------------------------------+
| 2030-01-02 03:04:05.000000     |
+--------------------------------+
1 row in set (0.00 sec)

```

## Potential risks
This does add a little more data to visibility, but as @lina-temporal
mentioned, the NextActionTime is not actually a difference in the volume
of writes to visibility. The Memo field is already being written every
schedule firing.
2026-05-29 19:25:15 +00:00

443 lines
14 KiB
Go

package scheduler_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
commonpb "go.temporal.io/api/common/v1"
enumspb "go.temporal.io/api/enums/v1"
schedulepb "go.temporal.io/api/schedule/v1"
taskqueuepb "go.temporal.io/api/taskqueue/v1"
workflowpb "go.temporal.io/api/workflow/v1"
"go.temporal.io/api/workflowservice/v1"
schedulespb "go.temporal.io/server/api/schedule/v1"
"go.temporal.io/server/chasm"
"go.temporal.io/server/chasm/lib/scheduler"
schedulerpb "go.temporal.io/server/chasm/lib/scheduler/gen/schedulerpb/v1"
"go.temporal.io/server/common/testing/protorequire"
"go.temporal.io/server/common/testing/testlogger"
"go.temporal.io/server/service/history/tasks"
"go.uber.org/mock/gomock"
"google.golang.org/protobuf/types/known/timestamppb"
)
func TestListInfo(t *testing.T) {
scheduler, ctx, _ := setupSchedulerForTest(t)
// Generator maintains the FutureActionTimes list, set that up first.
generator := scheduler.Generator.Get(ctx)
expectedFutureTimes := []*timestamppb.Timestamp{timestamppb.Now(), timestamppb.Now()}
generator.FutureActionTimes = expectedFutureTimes
listInfo := scheduler.ListInfo(ctx)
// Should return a populated info block.
require.NotNil(t, listInfo)
require.NotNil(t, listInfo.Spec)
require.NotEmpty(t, listInfo.Spec.Interval)
protorequire.ProtoEqual(t, listInfo.Spec.Interval[0], scheduler.Schedule.Spec.Interval[0])
require.NotNil(t, listInfo.WorkflowType)
require.NotEmpty(t, listInfo.FutureActionTimes)
require.Equal(t, expectedFutureTimes, listInfo.FutureActionTimes)
}
func TestCreateSchedulerFromMigration(t *testing.T) {
now := time.Now().UTC()
_, _, node := setupSchedulerForTest(t)
req := &schedulerpb.CreateFromMigrationStateRequest{
NamespaceId: namespaceID,
State: &schedulerpb.SchedulerMigrationState{
SchedulerState: &schedulerpb.SchedulerState{
Schedule: defaultSchedule(),
Info: &schedulepb.ScheduleInfo{ActionCount: 10},
Namespace: namespace,
NamespaceId: namespaceID,
ScheduleId: scheduleID,
ConflictToken: 42,
},
GeneratorState: &schedulerpb.GeneratorState{
LastProcessedTime: timestamppb.New(now),
},
InvokerState: &schedulerpb.InvokerState{
BufferedStarts: []*schedulespb.BufferedStart{
{
NominalTime: timestamppb.New(now),
ActualTime: timestamppb.New(now),
OverlapPolicy: enumspb.SCHEDULE_OVERLAP_POLICY_SKIP,
RequestId: "req-1",
WorkflowId: "wf-1",
},
{
NominalTime: timestamppb.New(now.Add(time.Minute)),
ActualTime: timestamppb.New(now.Add(time.Minute)),
OverlapPolicy: enumspb.SCHEDULE_OVERLAP_POLICY_ALLOW_ALL,
RequestId: "req-2",
WorkflowId: "wf-2",
},
},
},
Backfillers: map[string]*schedulerpb.BackfillerState{
"bf-1": {
BackfillId: "bf-1",
Request: &schedulerpb.BackfillerState_BackfillRequest{
BackfillRequest: &schedulepb.BackfillRequest{
StartTime: timestamppb.New(now.Add(-time.Hour)),
EndTime: timestamppb.New(now),
OverlapPolicy: enumspb.SCHEDULE_OVERLAP_POLICY_ALLOW_ALL,
},
},
},
},
LastCompletionResult: &schedulerpb.LastCompletionResult{
Success: &commonpb.Payload{Data: []byte("result-data")},
},
SearchAttributes: map[string]*commonpb.Payload{
"CustomAttr": {Data: []byte("attr-value")},
},
Memo: map[string]*commonpb.Payload{
"MemoKey": {Data: []byte("memo-value")},
},
},
}
ctx := chasm.NewMutableContext(context.Background(), node)
sched, err := scheduler.CreateSchedulerFromMigration(ctx, req)
require.NoError(t, err)
// Scheduler state
require.Equal(t, namespace, sched.Namespace)
require.Equal(t, namespaceID, sched.NamespaceId)
require.Equal(t, scheduleID, sched.ScheduleId)
require.Equal(t, int64(42), sched.ConflictToken)
require.False(t, sched.Closed)
// Generator
generator := sched.Generator.Get(ctx)
require.Equal(t, now, generator.LastProcessedTime.AsTime())
// Invoker buffered starts
invoker := sched.Invoker.Get(ctx)
require.Len(t, invoker.BufferedStarts, 2)
require.Equal(t, "req-1", invoker.BufferedStarts[0].RequestId)
require.Equal(t, "req-2", invoker.BufferedStarts[1].RequestId)
// Backfillers
require.Len(t, sched.Backfillers, 1)
bf := sched.Backfillers["bf-1"].Get(ctx)
require.Equal(t, "bf-1", bf.BackfillId)
// Last completion result
lastResult := sched.LastCompletionResult.Get(ctx)
require.Equal(t, []byte("result-data"), lastResult.Success.Data)
require.NoError(t, node.SetRootComponent(sched))
_, err = node.CloseTransaction()
require.NoError(t, err)
}
func TestUpdate_WithMemo(t *testing.T) {
sched, ctx, _ := setupSchedulerForTest(t)
memoValue := &commonpb.Payload{Data: []byte("test-value")}
_, err := sched.Update(ctx, &schedulerpb.UpdateScheduleRequest{
NamespaceId: namespaceID,
FrontendRequest: &workflowservice.UpdateScheduleRequest{
Namespace: namespace,
ScheduleId: scheduleID,
Schedule: defaultSchedule(),
Memo: &commonpb.Memo{
Fields: map[string]*commonpb.Payload{"key1": memoValue},
},
},
})
require.NoError(t, err)
visibility := sched.Visibility.Get(ctx)
memo := visibility.CustomMemo(ctx)
protorequire.ProtoEqual(t, memoValue, memo["key1"])
}
func TestUpdate_WithNilMemo(t *testing.T) {
sched, ctx, node := setupSchedulerForTest(t)
// Set initial memo.
visibility := sched.Visibility.Get(ctx)
visibility.MergeCustomMemo(ctx, map[string]*commonpb.Payload{
"existing": {Data: []byte("value")},
})
_, err := node.CloseTransaction()
require.NoError(t, err)
// Update without memo (nil) should preserve existing memo.
ctx = chasm.NewMutableContext(context.Background(), node)
_, err = sched.Update(ctx, &schedulerpb.UpdateScheduleRequest{
NamespaceId: namespaceID,
FrontendRequest: &workflowservice.UpdateScheduleRequest{
Namespace: namespace,
ScheduleId: scheduleID,
Schedule: defaultSchedule(),
},
})
require.NoError(t, err)
visibility = sched.Visibility.Get(ctx)
memo := visibility.CustomMemo(ctx)
protorequire.ProtoEqual(t, &commonpb.Payload{Data: []byte("value")}, memo["existing"])
}
func TestUpdate_MemoReplaceSemantics(t *testing.T) {
sched, ctx, node := setupSchedulerForTest(t)
// Set initial memo with keys A and B.
visibility := sched.Visibility.Get(ctx)
visibility.MergeCustomMemo(ctx, map[string]*commonpb.Payload{
"A": {Data: []byte("1")},
"B": {Data: []byte("2")},
})
_, err := node.CloseTransaction()
require.NoError(t, err)
// Update with only C: should fully replace memo (A and B are gone).
ctx = chasm.NewMutableContext(context.Background(), node)
_, err = sched.Update(ctx, &schedulerpb.UpdateScheduleRequest{
NamespaceId: namespaceID,
FrontendRequest: &workflowservice.UpdateScheduleRequest{
Namespace: namespace,
ScheduleId: scheduleID,
Schedule: defaultSchedule(),
Memo: &commonpb.Memo{
Fields: map[string]*commonpb.Payload{
"C": {Data: []byte("3")},
},
},
},
})
require.NoError(t, err)
visibility = sched.Visibility.Get(ctx)
memo := visibility.CustomMemo(ctx)
require.Nil(t, memo["A"], "A should be gone after replace")
require.Nil(t, memo["B"], "B should be gone after replace")
protorequire.ProtoEqual(t, &commonpb.Payload{Data: []byte("3")}, memo["C"])
// Update with empty memo: should clear all memo fields.
_, err = node.CloseTransaction()
require.NoError(t, err)
ctx = chasm.NewMutableContext(context.Background(), node)
_, err = sched.Update(ctx, &schedulerpb.UpdateScheduleRequest{
NamespaceId: namespaceID,
FrontendRequest: &workflowservice.UpdateScheduleRequest{
Namespace: namespace,
ScheduleId: scheduleID,
Schedule: defaultSchedule(),
Memo: &commonpb.Memo{
Fields: map[string]*commonpb.Payload{},
},
},
})
require.NoError(t, err)
visibility = sched.Visibility.Get(ctx)
memo = visibility.CustomMemo(ctx)
require.Empty(t, memo, "memo should be empty after replace with empty map")
}
func TestCreateSchedulerFromMigration_EmptyState(t *testing.T) {
_, _, node := setupSchedulerForTest(t)
req := &schedulerpb.CreateFromMigrationStateRequest{
NamespaceId: namespaceID,
State: &schedulerpb.SchedulerMigrationState{
SchedulerState: &schedulerpb.SchedulerState{
Schedule: defaultSchedule(),
Info: &schedulepb.ScheduleInfo{},
Namespace: namespace,
NamespaceId: namespaceID,
ScheduleId: scheduleID,
ConflictToken: 1,
},
GeneratorState: &schedulerpb.GeneratorState{},
InvokerState: &schedulerpb.InvokerState{},
},
}
ctx := chasm.NewMutableContext(context.Background(), node)
sched, err := scheduler.CreateSchedulerFromMigration(ctx, req)
require.NoError(t, err)
require.Equal(t, int64(1), sched.ConflictToken)
require.Empty(t, sched.Backfillers)
invoker := sched.Invoker.Get(ctx)
require.Empty(t, invoker.BufferedStarts)
require.NoError(t, node.SetRootComponent(sched))
_, err = node.CloseTransaction()
require.NoError(t, err)
}
func TestCreateSchedulerFromMigration_NoRunning(t *testing.T) {
ctrl := gomock.NewController(t)
infra := setupTestInfra(t, newRealSpecProcessor(ctrl, testlogger.NewTestLogger(t, testlogger.FailOnExpectedErrorOnly)))
ctx := chasm.NewMutableContext(context.Background(), infra.node)
req := &schedulerpb.CreateFromMigrationStateRequest{
NamespaceId: namespaceID,
State: &schedulerpb.SchedulerMigrationState{
SchedulerState: &schedulerpb.SchedulerState{
Schedule: defaultSchedule(),
Info: &schedulepb.ScheduleInfo{},
Namespace: namespace,
NamespaceId: namespaceID,
ScheduleId: scheduleID,
ConflictToken: 1,
},
GeneratorState: &schedulerpb.GeneratorState{
LastProcessedTime: timestamppb.New(time.Now().Add(-time.Hour)),
},
InvokerState: &schedulerpb.InvokerState{},
},
}
sched, err := scheduler.CreateSchedulerFromMigration(ctx, req)
require.NoError(t, err)
require.NoError(t, infra.node.SetRootComponent(sched))
_, err = infra.node.CloseTransaction()
require.NoError(t, err)
hasGeneratorTask := false
outer:
for _, taskList := range infra.nodeBackend.TasksByCategory {
for _, task := range taskList {
chasmTask, ok := task.(*tasks.ChasmTask)
if ok && chasmTask.GetVisibilityTime().Equal(chasm.TaskScheduledTimeImmediate) {
hasGeneratorTask = true
break outer
}
}
}
require.True(t, hasGeneratorTask,
"expected an immediate GeneratorTask after migration with no running workflows")
}
func TestContextMetadata(t *testing.T) {
t.Run("returns workflow type and task queue", func(t *testing.T) {
sched, ctx, _ := setupSchedulerForTest(t)
sched.Schedule.Action = &schedulepb.ScheduleAction{
Action: &schedulepb.ScheduleAction_StartWorkflow{
StartWorkflow: &workflowpb.NewWorkflowExecutionInfo{
WorkflowType: &commonpb.WorkflowType{Name: "my-workflow"},
TaskQueue: &taskqueuepb.TaskQueue{Name: "my-task-queue"},
},
},
}
md := sched.ContextMetadata(ctx)
require.Equal(t, map[string]string{
"workflow-type": "my-workflow",
"workflow-task-queue": "my-task-queue",
}, md)
})
t.Run("returns only workflow type when task queue is empty", func(t *testing.T) {
sched, ctx, _ := setupSchedulerForTest(t)
// defaultSchedule sets WorkflowType but no TaskQueue
md := sched.ContextMetadata(ctx)
require.Equal(t, map[string]string{
"workflow-type": "scheduled-wf-type",
}, md)
})
t.Run("returns nil when action is empty", func(t *testing.T) {
sched, ctx, _ := setupSchedulerForTest(t)
sched.Schedule.Action = nil
md := sched.ContextMetadata(ctx)
require.Nil(t, md)
})
}
func TestSearchAttributes_NextActionTime(t *testing.T) {
t.Run("open with future actions emits NextActionTime", func(t *testing.T) {
sched, ctx, _ := setupSchedulerForTest(t)
nextAction := time.Now().Add(15 * time.Minute).UTC().Truncate(time.Second)
later := nextAction.Add(time.Hour)
generator := sched.Generator.Get(ctx)
generator.FutureActionTimes = []*timestamppb.Timestamp{
timestamppb.New(nextAction),
timestamppb.New(later),
}
sas := sched.SearchAttributes(ctx)
val, ok := findSearchAttribute(t, sas, scheduler.ScheduleNextActionTimeName)
require.True(t, ok, "expected %s to be present", scheduler.ScheduleNextActionTimeName)
require.True(t, nextAction.Equal(val.(time.Time)), "want %v, got %v", nextAction, val)
})
t.Run("open with no future actions does not emit", func(t *testing.T) {
sched, ctx, _ := setupSchedulerForTest(t)
generator := sched.Generator.Get(ctx)
generator.FutureActionTimes = nil
sas := sched.SearchAttributes(ctx)
_, hasNext := findSearchAttribute(t, sas, scheduler.ScheduleNextActionTimeName)
require.False(t, hasNext, "expected %s to be absent when there is no upcoming action", scheduler.ScheduleNextActionTimeName)
})
t.Run("closed does not emit NextActionTime", func(t *testing.T) {
sched, ctx, _ := setupSchedulerForTest(t)
sched.Closed = true
// Future action times left over from before close must not leak through.
generator := sched.Generator.Get(ctx)
generator.FutureActionTimes = []*timestamppb.Timestamp{timestamppb.New(time.Now().Add(time.Hour))}
sas := sched.SearchAttributes(ctx)
_, hasNext := findSearchAttribute(t, sas, scheduler.ScheduleNextActionTimeName)
require.False(t, hasNext, "expected %s to be absent once closed", scheduler.ScheduleNextActionTimeName)
})
t.Run("sentinel does not emit", func(t *testing.T) {
sentinel, ctx, _ := setupSentinelForTest(t)
sas := sentinel.SearchAttributes(ctx)
_, hasNext := findSearchAttribute(t, sas, scheduler.ScheduleNextActionTimeName)
require.False(t, hasNext)
})
}
func findSearchAttribute(t *testing.T, sas []chasm.SearchAttributeKeyValue, alias string) (any, bool) {
t.Helper()
for _, sa := range sas {
if sa.Alias == alias {
return sa.Value.Value(), true
}
}
return nil, false
}
// TestSearchAttributes_RoundTripThroughCloseTransaction verifies that
// TemporalScheduleNextActionTime survives registration/serialization by
// flowing through the same CHASM pipeline that runs in production —
// closeTransactionForceUpdateVisibility, which calls SearchAttributes() on
// the root component and snapshots the result. If the attribute were
// unregistered or had a bad value type, the CloseTransaction call would
// error or panic.
func TestSearchAttributes_RoundTripThroughCloseTransaction(t *testing.T) {
sched, ctx, node := setupSchedulerForTest(t)
nextAction := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
generator := sched.Generator.Get(ctx)
generator.FutureActionTimes = []*timestamppb.Timestamp{timestamppb.New(nextAction)}
require.NoError(t, node.SetRootComponent(sched))
_, err := node.CloseTransaction()
require.NoError(t, err, "CloseTransaction should accept TemporalScheduleNextActionTime")
}