Files
tasks/tests/schedule.go
David ReissandYichao Yang 5d84918cf0 Use proto encoding for scheduler workflow next time cache (#5277)
The scheduler workflow keeps a cache of a sequence of calls to
getNextTime to reduce overhead. The cache was serialized as json, this
changes it to serialize as proto. This is made forwards and backwards
compatible by trying to deserialize into both types.

The json decoding of this cache showed up as a significant cost in a cpu
profile of a worker that was doing a lot of schedule workflow replays.
In a simple benchmark of decoding only on my laptop, decoding the proto
was about 20 times faster with no jitter (14 with jitter), and the
encoded proto was 8 times smaller (5 with jitter). Encoding was about 4
times faster (but that's less important).

```
no jitter:
BenchmarkDecodeJson-12            178678             33114 ns/op            1848 B/op         13 allocs/op
BenchmarkDecodeProto-12          2249361              1571 ns/op             288 B/op          3 allocs/op
with jitter:
BenchmarkDecodeJson-12            110619             32657 ns/op            1848 B/op         13 allocs/op
BenchmarkDecodeProto-12          1565641              2336 ns/op             400 B/op          4 allocs/op
```

existing tests (especially replay test for backwards + forwards
compatibility)

The code is a little more complicated and there could be bugs in the
conversion. Note that both json and proto support nanosecond resolution
for timestamps.
2024-04-26 13:48:43 -07:00

906 lines
30 KiB
Go

// The MIT License
//
// Copyright (c) 2020 Temporal Technologies Inc. All rights reserved.
//
// Copyright (c) 2020 Uber Technologies, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
package tests
import (
"errors"
"fmt"
"strings"
"sync/atomic"
"time"
"github.com/pborman/uuid"
"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"
sdkclient "go.temporal.io/sdk/client"
"go.temporal.io/sdk/converter"
"go.temporal.io/sdk/worker"
"go.temporal.io/sdk/workflow"
"google.golang.org/protobuf/types/known/durationpb"
"go.temporal.io/server/common/dynamicconfig"
"go.temporal.io/server/common/log/tag"
"go.temporal.io/server/common/payload"
"go.temporal.io/server/common/payloads"
"go.temporal.io/server/common/searchattribute"
"go.temporal.io/server/common/testing/protorequire"
"go.temporal.io/server/service/worker/scheduler"
)
/*
more tests to write:
various validation errors
overlap policies, esp. buffer
worker restart/long-poll activity failure:
get it in a state where it's waiting for a wf to exit, say with bufferone
restart the worker/force activity to fail
terminate the wf
check that new one starts immediately
*/
type (
ScheduleFunctionalSuite struct {
*require.Assertions
protorequire.ProtoAssertions
FunctionalTestBase
sdkClient sdkclient.Client
worker worker.Worker
taskQueue string
dataConverter converter.DataConverter
}
)
func (s *ScheduleFunctionalSuite) SetupSuite() {
if UsingSQLAdvancedVisibility() {
s.setupSuite("testdata/cluster.yaml")
s.Logger.Info(fmt.Sprintf("Running schedule tests with %s/%s persistence", TestFlags.PersistenceType, TestFlags.PersistenceDriver))
} else {
s.setupSuite("testdata/es_cluster.yaml")
s.Logger.Info("Running schedule tests with Elasticsearch persistence")
}
}
func (s *ScheduleFunctionalSuite) TearDownSuite() {
s.tearDownSuite()
}
func (s *ScheduleFunctionalSuite) SetupTest() {
s.Assertions = require.New(s.T())
s.ProtoAssertions = protorequire.New(s.T())
s.dataConverter = newTestDataConverter()
sdkClient, err := sdkclient.Dial(sdkclient.Options{
HostPort: s.hostPort,
Namespace: s.namespace,
DataConverter: s.dataConverter,
})
if err != nil {
s.Logger.Fatal("Error when creating SDK client", tag.Error(err))
}
s.sdkClient = sdkClient
s.taskQueue = s.randomizeStr("tq")
s.worker = worker.New(s.sdkClient, s.taskQueue, worker.Options{})
if err := s.worker.Start(); err != nil {
s.Logger.Fatal("Error when starting worker", tag.Error(err))
}
}
func (s *ScheduleFunctionalSuite) TearDownTest() {
s.worker.Stop()
s.sdkClient.Close()
}
func (s *ScheduleFunctionalSuite) TestBasics() {
sid := "sched-test-basics"
wid := "sched-test-basics-wf"
wt := "sched-test-basics-wt"
wt2 := "sched-test-basics-wt2"
// switch this to test with search attribute mapper:
// csa := "AliasForCustomKeywordField"
csa := "CustomKeywordField"
wfMemo := payload.EncodeString("workflow memo")
wfSAValue := payload.EncodeString("workflow sa value")
schMemo := payload.EncodeString("schedule memo")
schSAValue := payload.EncodeString("schedule sa value")
schedule := &schedulepb.Schedule{
Spec: &schedulepb.ScheduleSpec{
Interval: []*schedulepb.IntervalSpec{
{Interval: durationpb.New(5 * time.Second)},
},
Calendar: []*schedulepb.CalendarSpec{
{DayOfMonth: "10", Year: "2010"},
},
CronString: []string{"11 11/11 11 11 1 2011"},
},
Action: &schedulepb.ScheduleAction{
Action: &schedulepb.ScheduleAction_StartWorkflow{
StartWorkflow: &workflowpb.NewWorkflowExecutionInfo{
WorkflowId: wid,
WorkflowType: &commonpb.WorkflowType{Name: wt},
TaskQueue: &taskqueuepb.TaskQueue{Name: s.taskQueue, Kind: enumspb.TASK_QUEUE_KIND_NORMAL},
Memo: &commonpb.Memo{
Fields: map[string]*commonpb.Payload{"wfmemo1": wfMemo},
},
SearchAttributes: &commonpb.SearchAttributes{
IndexedFields: map[string]*commonpb.Payload{csa: wfSAValue},
},
},
},
},
}
req := &workflowservice.CreateScheduleRequest{
Namespace: s.namespace,
ScheduleId: sid,
Schedule: schedule,
Identity: "test",
RequestId: uuid.New(),
Memo: &commonpb.Memo{
Fields: map[string]*commonpb.Payload{"schedmemo1": schMemo},
},
SearchAttributes: &commonpb.SearchAttributes{
IndexedFields: map[string]*commonpb.Payload{csa: schSAValue},
},
}
var runs, runs2 int32
workflowFn := func(ctx workflow.Context) error {
workflow.SideEffect(ctx, func(ctx workflow.Context) any {
atomic.AddInt32(&runs, 1)
return 0
})
return nil
}
s.worker.RegisterWorkflowWithOptions(workflowFn, workflow.RegisterOptions{Name: wt})
workflow2Fn := func(ctx workflow.Context) error {
workflow.SideEffect(ctx, func(ctx workflow.Context) any {
atomic.AddInt32(&runs2, 1)
return 0
})
return nil
}
s.worker.RegisterWorkflowWithOptions(workflow2Fn, workflow.RegisterOptions{Name: wt2})
// create
createTime := time.Now()
_, err := s.engine.CreateSchedule(NewContext(), req)
s.NoError(err)
// sleep until we see two runs, plus a bit more to ensure that the second run has completed
s.Eventually(func() bool { return atomic.LoadInt32(&runs) == 2 }, 12*time.Second, 500*time.Millisecond)
time.Sleep(1 * time.Second)
// describe
describeResp, err := s.engine.DescribeSchedule(NewContext(), &workflowservice.DescribeScheduleRequest{
Namespace: s.namespace,
ScheduleId: sid,
})
s.NoError(err)
checkSpec := func(spec *schedulepb.ScheduleSpec) {
protorequire.ProtoSliceEqual(s.T(), schedule.Spec.Interval, spec.Interval)
s.Nil(spec.Calendar)
s.Nil(spec.CronString)
s.ProtoElementsMatch([]*schedulepb.StructuredCalendarSpec{
{
Second: []*schedulepb.Range{{Start: 0, End: 0, Step: 1}},
Minute: []*schedulepb.Range{{Start: 11, End: 11, Step: 1}},
Hour: []*schedulepb.Range{{Start: 11, End: 23, Step: 11}},
DayOfMonth: []*schedulepb.Range{{Start: 11, End: 11, Step: 1}},
Month: []*schedulepb.Range{{Start: 11, End: 11, Step: 1}},
DayOfWeek: []*schedulepb.Range{{Start: 1, End: 1, Step: 1}},
Year: []*schedulepb.Range{{Start: 2011, End: 2011, Step: 1}},
},
{
Second: []*schedulepb.Range{{Start: 0, End: 0, Step: 1}},
Minute: []*schedulepb.Range{{Start: 0, End: 0, Step: 1}},
Hour: []*schedulepb.Range{{Start: 0, End: 0, Step: 1}},
DayOfMonth: []*schedulepb.Range{{Start: 10, End: 10, Step: 1}},
Month: []*schedulepb.Range{{Start: 1, End: 12, Step: 1}},
DayOfWeek: []*schedulepb.Range{{Start: 0, End: 6, Step: 1}},
Year: []*schedulepb.Range{{Start: 2010, End: 2010, Step: 1}},
},
}, spec.StructuredCalendar)
}
checkSpec(describeResp.Schedule.Spec)
s.Equal(enumspb.SCHEDULE_OVERLAP_POLICY_SKIP, describeResp.Schedule.Policies.OverlapPolicy) // set to default value
s.EqualValues(365*24*3600, describeResp.Schedule.Policies.CatchupWindow.AsDuration().Seconds()) // set to default value
s.Equal(schSAValue.Data, describeResp.SearchAttributes.IndexedFields[csa].Data)
s.Nil(describeResp.SearchAttributes.IndexedFields[searchattribute.BinaryChecksums])
s.Nil(describeResp.SearchAttributes.IndexedFields[searchattribute.BuildIds])
s.Equal(schMemo.Data, describeResp.Memo.Fields["schedmemo1"].Data)
s.Equal(wfSAValue.Data, describeResp.Schedule.Action.GetStartWorkflow().SearchAttributes.IndexedFields[csa].Data)
s.Equal(wfMemo.Data, describeResp.Schedule.Action.GetStartWorkflow().Memo.Fields["wfmemo1"].Data)
s.DurationNear(describeResp.Info.CreateTime.AsTime().Sub(createTime), 0, 3*time.Second)
s.EqualValues(2, describeResp.Info.ActionCount)
s.EqualValues(0, describeResp.Info.MissedCatchupWindow)
s.EqualValues(0, describeResp.Info.OverlapSkipped)
s.EqualValues(0, len(describeResp.Info.RunningWorkflows))
s.EqualValues(2, len(describeResp.Info.RecentActions))
action0 := describeResp.Info.RecentActions[0]
s.WithinRange(action0.ScheduleTime.AsTime(), createTime, time.Now())
s.True(action0.ScheduleTime.AsTime().UnixNano()%int64(5*time.Second) == 0)
s.DurationNear(action0.ActualTime.AsTime().Sub(action0.ScheduleTime.AsTime()), 0, 3*time.Second)
// list
s.Eventually(func() bool { // wait for visibility
listResp, err := s.engine.ListSchedules(NewContext(), &workflowservice.ListSchedulesRequest{
Namespace: s.namespace,
MaximumPageSize: 5,
})
if err != nil || len(listResp.Schedules) != 1 || listResp.Schedules[0].ScheduleId != sid || len(listResp.Schedules[0].GetInfo().GetRecentActions()) < 2 {
return false
}
s.NoError(err)
entry := listResp.Schedules[0]
s.Equal(sid, entry.ScheduleId)
s.Equal(schSAValue.Data, entry.SearchAttributes.IndexedFields[csa].Data)
s.Equal(schMemo.Data, entry.Memo.Fields["schedmemo1"].Data)
checkSpec(entry.Info.Spec)
s.Equal(wt, entry.Info.WorkflowType.Name)
s.False(entry.Info.Paused)
s.Equal(describeResp.Info.RecentActions, entry.Info.RecentActions) // 2 is below the limit where list entry might be cut off
return true
}, 10*time.Second, 1*time.Second)
// list workflows
wfResp, err := s.engine.ListWorkflowExecutions(NewContext(), &workflowservice.ListWorkflowExecutionsRequest{
Namespace: s.namespace,
PageSize: 5,
Query: "",
})
s.NoError(err)
s.GreaterOrEqual(len(wfResp.Executions), 2) // could have had a 3rd run while waiting for visibility
for _, ex := range wfResp.Executions {
s.Equal(wt, ex.Type.Name, "should only see started workflows")
}
ex0 := wfResp.Executions[0]
s.True(strings.HasPrefix(ex0.Execution.WorkflowId, wid))
s.True(ex0.Execution.RunId == describeResp.Info.RecentActions[0].GetStartWorkflowResult().RunId ||
ex0.Execution.RunId == describeResp.Info.RecentActions[1].GetStartWorkflowResult().RunId)
s.Equal(wt, ex0.Type.Name)
s.Nil(ex0.ParentExecution) // not a child workflow
s.Equal(wfMemo.Data, ex0.Memo.Fields["wfmemo1"].Data)
s.Equal(wfSAValue.Data, ex0.SearchAttributes.IndexedFields[csa].Data)
s.Equal(payload.EncodeString(sid).Data, ex0.SearchAttributes.IndexedFields[searchattribute.TemporalScheduledById].Data)
var ex0StartTime time.Time
s.NoError(payload.Decode(ex0.SearchAttributes.IndexedFields[searchattribute.TemporalScheduledStartTime], &ex0StartTime))
s.WithinRange(ex0StartTime, createTime, time.Now())
s.True(ex0StartTime.UnixNano()%int64(5*time.Second) == 0)
// list with QueryWithAnyNamespaceDivision, we should see the scheduler workflow
wfResp, err = s.engine.ListWorkflowExecutions(NewContext(), &workflowservice.ListWorkflowExecutionsRequest{
Namespace: s.namespace,
PageSize: 5,
Query: searchattribute.QueryWithAnyNamespaceDivision(`ExecutionStatus = "Running"`),
})
s.NoError(err)
count := 0
for _, ex := range wfResp.Executions {
if ex.Type.Name == scheduler.WorkflowType {
count++
}
}
s.EqualValues(1, count, "should see scheduler workflow")
// list workflows with an exact match on namespace division (implementation details here, not public api)
wfResp, err = s.engine.ListWorkflowExecutions(NewContext(), &workflowservice.ListWorkflowExecutionsRequest{
Namespace: s.namespace,
PageSize: 5,
Query: fmt.Sprintf("%s = \"%s\"", searchattribute.TemporalNamespaceDivision, scheduler.NamespaceDivision),
})
s.NoError(err)
s.EqualValues(1, len(wfResp.Executions), "should see scheduler workflow")
ex0 = wfResp.Executions[0]
s.Equal(scheduler.WorkflowType, ex0.Type.Name)
// update
schedule.Spec.Interval[0].Phase = durationpb.New(1 * time.Second)
schedule.Action.GetStartWorkflow().WorkflowType.Name = wt2
updateTime := time.Now()
_, err = s.engine.UpdateSchedule(NewContext(), &workflowservice.UpdateScheduleRequest{
Namespace: s.namespace,
ScheduleId: sid,
Schedule: schedule,
Identity: "test",
RequestId: uuid.New(),
})
s.NoError(err)
// wait for one new run
s.Eventually(func() bool { return atomic.LoadInt32(&runs2) == 1 }, 7*time.Second, 500*time.Millisecond)
// describe again
describeResp, err = s.engine.DescribeSchedule(NewContext(), &workflowservice.DescribeScheduleRequest{
Namespace: s.namespace,
ScheduleId: sid,
})
s.NoError(err)
s.Equal(schSAValue.Data, describeResp.SearchAttributes.IndexedFields[csa].Data)
s.Equal(schMemo.Data, describeResp.Memo.Fields["schedmemo1"].Data)
s.Equal(wfSAValue.Data, describeResp.Schedule.Action.GetStartWorkflow().SearchAttributes.IndexedFields[csa].Data)
s.Equal(wfMemo.Data, describeResp.Schedule.Action.GetStartWorkflow().Memo.Fields["wfmemo1"].Data)
s.DurationNear(describeResp.Info.UpdateTime.AsTime().Sub(updateTime), 0, 3*time.Second)
lastAction := describeResp.Info.RecentActions[len(describeResp.Info.RecentActions)-1]
s.True(lastAction.ScheduleTime.AsTime().UnixNano()%int64(5*time.Second) == 1000000000, lastAction.ScheduleTime.AsTime().UnixNano())
// pause
_, err = s.engine.PatchSchedule(NewContext(), &workflowservice.PatchScheduleRequest{
Namespace: s.namespace,
ScheduleId: sid,
Patch: &schedulepb.SchedulePatch{
Pause: "because I said so",
},
Identity: "test",
RequestId: uuid.New(),
})
s.NoError(err)
time.Sleep(7 * time.Second)
s.EqualValues(1, atomic.LoadInt32(&runs2), "has not run again")
describeResp, err = s.engine.DescribeSchedule(NewContext(), &workflowservice.DescribeScheduleRequest{
Namespace: s.namespace,
ScheduleId: sid,
})
s.NoError(err)
s.True(describeResp.Schedule.State.Paused)
s.Equal("because I said so", describeResp.Schedule.State.Notes)
// don't loop to wait for visibility, we already waited 7s from the patch
listResp, err := s.engine.ListSchedules(NewContext(), &workflowservice.ListSchedulesRequest{
Namespace: s.namespace,
MaximumPageSize: 5,
})
s.NoError(err)
s.Equal(1, len(listResp.Schedules))
entry := listResp.Schedules[0]
s.Equal(sid, entry.ScheduleId)
s.True(entry.Info.Paused)
s.Equal("because I said so", entry.Info.Notes)
// finally delete
_, err = s.engine.DeleteSchedule(NewContext(), &workflowservice.DeleteScheduleRequest{
Namespace: s.namespace,
ScheduleId: sid,
Identity: "test",
})
s.NoError(err)
describeResp, err = s.engine.DescribeSchedule(NewContext(), &workflowservice.DescribeScheduleRequest{
Namespace: s.namespace,
ScheduleId: sid,
})
s.Error(err)
s.Eventually(func() bool { // wait for visibility
listResp, err := s.engine.ListSchedules(NewContext(), &workflowservice.ListSchedulesRequest{
Namespace: s.namespace,
MaximumPageSize: 5,
})
s.NoError(err)
return len(listResp.Schedules) == 0
}, 10*time.Second, 1*time.Second)
}
func (s *ScheduleFunctionalSuite) TestInput() {
sid := "sched-test-input"
wid := "sched-test-input-wf"
wt := "sched-test-input-wt"
type myData struct {
Stuff string
Things []int
}
input1 := &myData{
Stuff: "here's some data",
Things: []int{7, 8, 9},
}
input2 := map[int]float64{11: 1.4375}
inputPayloads, err := s.dataConverter.ToPayloads(input1, input2)
s.NoError(err)
schedule := &schedulepb.Schedule{
Spec: &schedulepb.ScheduleSpec{
Interval: []*schedulepb.IntervalSpec{
{Interval: durationpb.New(3 * time.Second)},
},
},
Action: &schedulepb.ScheduleAction{
Action: &schedulepb.ScheduleAction_StartWorkflow{
StartWorkflow: &workflowpb.NewWorkflowExecutionInfo{
WorkflowId: wid,
WorkflowType: &commonpb.WorkflowType{Name: wt},
TaskQueue: &taskqueuepb.TaskQueue{Name: s.taskQueue, Kind: enumspb.TASK_QUEUE_KIND_NORMAL},
Input: inputPayloads,
},
},
},
}
req := &workflowservice.CreateScheduleRequest{
Namespace: s.namespace,
ScheduleId: sid,
Schedule: schedule,
Identity: "test",
RequestId: uuid.New(),
}
var runs int32
workflowFn := func(ctx workflow.Context, arg1 *myData, arg2 map[int]float64) error {
workflow.SideEffect(ctx, func(ctx workflow.Context) any {
s.Equal(*input1, *arg1)
s.Equal(input2, arg2)
atomic.AddInt32(&runs, 1)
return 0
})
return nil
}
s.worker.RegisterWorkflowWithOptions(workflowFn, workflow.RegisterOptions{Name: wt})
_, err = s.engine.CreateSchedule(NewContext(), req)
s.NoError(err)
s.Eventually(func() bool { return atomic.LoadInt32(&runs) == 1 }, 5*time.Second, 200*time.Millisecond)
// cleanup
_, err = s.engine.DeleteSchedule(NewContext(), &workflowservice.DeleteScheduleRequest{
Namespace: s.namespace,
ScheduleId: sid,
Identity: "test",
})
s.NoError(err)
}
func (s *ScheduleFunctionalSuite) TestLastCompletionAndError() {
sid := "sched-test-last"
wid := "sched-test-last-wf"
wt := "sched-test-last-wt"
schedule := &schedulepb.Schedule{
Spec: &schedulepb.ScheduleSpec{
Interval: []*schedulepb.IntervalSpec{
{Interval: durationpb.New(3 * time.Second)},
},
},
Action: &schedulepb.ScheduleAction{
Action: &schedulepb.ScheduleAction_StartWorkflow{
StartWorkflow: &workflowpb.NewWorkflowExecutionInfo{
WorkflowId: wid,
WorkflowType: &commonpb.WorkflowType{Name: wt},
TaskQueue: &taskqueuepb.TaskQueue{Name: s.taskQueue, Kind: enumspb.TASK_QUEUE_KIND_NORMAL},
},
},
},
}
req := &workflowservice.CreateScheduleRequest{
Namespace: s.namespace,
ScheduleId: sid,
Schedule: schedule,
Identity: "test",
RequestId: uuid.New(),
}
runs := make(map[string]struct{})
var testComplete int32
workflowFn := func(ctx workflow.Context) (string, error) {
var num int
_ = workflow.SideEffect(ctx, func(ctx workflow.Context) any {
runs[workflow.GetInfo(ctx).WorkflowExecution.ID] = struct{}{}
return len(runs)
}).Get(&num)
var lcr string
if workflow.HasLastCompletionResult(ctx) {
s.NoError(workflow.GetLastCompletionResult(ctx, &lcr))
}
lastErr := workflow.GetLastError(ctx)
switch num {
case 1:
s.Equal("", lcr)
s.NoError(lastErr)
return "this one succeeds", nil
case 2:
s.NoError(lastErr)
s.Equal("this one succeeds", lcr)
return "", errors.New("this one fails")
case 3:
s.Equal("this one succeeds", lcr)
s.ErrorContains(lastErr, "this one fails")
atomic.StoreInt32(&testComplete, 1)
return "done", nil
default:
panic("shouldn't be running anymore")
}
}
s.worker.RegisterWorkflowWithOptions(workflowFn, workflow.RegisterOptions{Name: wt})
_, err := s.engine.CreateSchedule(NewContext(), req)
s.NoError(err)
s.Eventually(func() bool { return atomic.LoadInt32(&testComplete) == 1 }, 15*time.Second, 200*time.Millisecond)
// cleanup
_, err = s.engine.DeleteSchedule(NewContext(), &workflowservice.DeleteScheduleRequest{
Namespace: s.namespace,
ScheduleId: sid,
Identity: "test",
})
s.NoError(err)
}
func (s *ScheduleFunctionalSuite) TestRefresh() {
sid := "sched-test-refresh"
wid := "sched-test-refresh-wf"
wt := "sched-test-refresh-wt"
schedule := &schedulepb.Schedule{
Spec: &schedulepb.ScheduleSpec{
Interval: []*schedulepb.IntervalSpec{
{
Interval: durationpb.New(30 * time.Second),
// start within three seconds
Phase: durationpb.New(time.Duration((time.Now().Unix()+3)%30) * time.Second),
},
},
},
Action: &schedulepb.ScheduleAction{
Action: &schedulepb.ScheduleAction_StartWorkflow{
StartWorkflow: &workflowpb.NewWorkflowExecutionInfo{
WorkflowId: wid,
WorkflowType: &commonpb.WorkflowType{Name: wt},
TaskQueue: &taskqueuepb.TaskQueue{Name: s.taskQueue, Kind: enumspb.TASK_QUEUE_KIND_NORMAL},
WorkflowExecutionTimeout: durationpb.New(3 * time.Second),
},
},
},
}
req := &workflowservice.CreateScheduleRequest{
Namespace: s.namespace,
ScheduleId: sid,
Schedule: schedule,
Identity: "test",
RequestId: uuid.New(),
}
var runs int32
workflowFn := func(ctx workflow.Context) error {
workflow.SideEffect(ctx, func(ctx workflow.Context) any {
atomic.AddInt32(&runs, 1)
return 0
})
s.NoError(workflow.Sleep(ctx, 10*time.Second)) // longer than execution timeout
return nil
}
s.worker.RegisterWorkflowWithOptions(workflowFn, workflow.RegisterOptions{Name: wt})
_, err := s.engine.CreateSchedule(NewContext(), req)
s.NoError(err)
s.Eventually(func() bool { return atomic.LoadInt32(&runs) == 1 }, 6*time.Second, 200*time.Millisecond)
// workflow has started but is now sleeping. it will timeout in 2 seconds.
describeResp, err := s.engine.DescribeSchedule(NewContext(), &workflowservice.DescribeScheduleRequest{
Namespace: s.namespace,
ScheduleId: sid,
})
s.NoError(err)
s.EqualValues(1, len(describeResp.Info.RunningWorkflows))
events1 := s.getHistory(s.namespace, &commonpb.WorkflowExecution{WorkflowId: scheduler.WorkflowIDPrefix + sid})
time.Sleep(4 * time.Second)
// now it has timed out, but the scheduler hasn't noticed yet. we can prove it by checking
// its history.
events2 := s.getHistory(s.namespace, &commonpb.WorkflowExecution{WorkflowId: scheduler.WorkflowIDPrefix + sid})
s.Equal(len(events1), len(events2))
// when we describe we'll force a refresh and see it timed out
describeResp, err = s.engine.DescribeSchedule(NewContext(), &workflowservice.DescribeScheduleRequest{
Namespace: s.namespace,
ScheduleId: sid,
})
s.NoError(err)
s.EqualValues(0, len(describeResp.Info.RunningWorkflows))
// check scheduler has gotten the refresh and done some stuff. signal is sent without waiting so we need to wait.
s.Eventually(func() bool {
events3 := s.getHistory(s.namespace, &commonpb.WorkflowExecution{WorkflowId: scheduler.WorkflowIDPrefix + sid})
return len(events3) > len(events2)
}, 5*time.Second, 100*time.Millisecond)
// cleanup
_, err = s.engine.DeleteSchedule(NewContext(), &workflowservice.DeleteScheduleRequest{
Namespace: s.namespace,
ScheduleId: sid,
Identity: "test",
})
s.NoError(err)
}
func (s *ScheduleFunctionalSuite) TestListBeforeRun() {
sid := "sched-test-list-before-run"
wid := "sched-test-list-before-run-wf"
wt := "sched-test-list-before-run-wt"
// disable per-ns worker so that the schedule workflow never runs
dc := s.testCluster.host.dcClient
dc.OverrideValue(s.T(), dynamicconfig.WorkerPerNamespaceWorkerCount, 0)
s.testCluster.host.workerService.RefreshPerNSWorkerManager()
time.Sleep(2 * time.Second)
schedule := &schedulepb.Schedule{
Spec: &schedulepb.ScheduleSpec{
Interval: []*schedulepb.IntervalSpec{
{Interval: durationpb.New(3 * time.Second)},
},
},
Action: &schedulepb.ScheduleAction{
Action: &schedulepb.ScheduleAction_StartWorkflow{
StartWorkflow: &workflowpb.NewWorkflowExecutionInfo{
WorkflowId: wid,
WorkflowType: &commonpb.WorkflowType{Name: wt},
TaskQueue: &taskqueuepb.TaskQueue{Name: s.taskQueue, Kind: enumspb.TASK_QUEUE_KIND_NORMAL},
},
},
},
}
req := &workflowservice.CreateScheduleRequest{
Namespace: s.namespace,
ScheduleId: sid,
Schedule: schedule,
Identity: "test",
RequestId: uuid.New(),
}
startTime := time.Now()
_, err := s.engine.CreateSchedule(NewContext(), req)
s.NoError(err)
s.Eventually(func() bool { // wait for visibility
listResp, err := s.engine.ListSchedules(NewContext(), &workflowservice.ListSchedulesRequest{
Namespace: s.namespace,
MaximumPageSize: 5,
})
if err != nil || len(listResp.Schedules) != 1 || listResp.Schedules[0].ScheduleId != sid {
return false
}
s.NoError(err)
entry := listResp.Schedules[0]
s.Equal(sid, entry.ScheduleId)
s.NotNil(entry.Info)
s.ProtoEqual(schedule.Spec, entry.Info.Spec)
s.Equal(wt, entry.Info.WorkflowType.Name)
s.False(entry.Info.Paused)
s.Greater(len(entry.Info.FutureActionTimes), 1)
s.True(entry.Info.FutureActionTimes[0].AsTime().After(startTime))
return true
}, 10*time.Second, 1*time.Second)
// cleanup
_, err = s.engine.DeleteSchedule(NewContext(), &workflowservice.DeleteScheduleRequest{
Namespace: s.namespace,
ScheduleId: sid,
Identity: "test",
})
s.NoError(err)
dc.RemoveOverride(dynamicconfig.WorkerPerNamespaceWorkerCount)
s.testCluster.host.workerService.RefreshPerNSWorkerManager()
time.Sleep(2 * time.Second)
}
func (s *ScheduleFunctionalSuite) TestRateLimit() {
sid := "sched-test-rate-limit-%d"
wid := "sched-test-rate-limit-wf-%d"
wt := "sched-test-rate-limit-wt"
// Set 1/sec rate limit per namespace. To force this to take effect immediately (instead of
// waiting one minute) we have to cause the whole worker to be stopped and started. The
// sleeps are needed because the refresh is asynchronous, and there's no way to get access
// to the actual rate limiter object to refresh it directly.
s.testCluster.host.dcClient.OverrideValue(s.T(), dynamicconfig.SchedulerNamespaceStartWorkflowRPS, 1.0)
s.testCluster.host.dcClient.OverrideValue(s.T(), dynamicconfig.WorkerPerNamespaceWorkerCount, 0)
s.testCluster.host.workerService.RefreshPerNSWorkerManager()
time.Sleep(2 * time.Second)
s.testCluster.host.dcClient.RemoveOverride(dynamicconfig.WorkerPerNamespaceWorkerCount)
s.testCluster.host.workerService.RefreshPerNSWorkerManager()
time.Sleep(2 * time.Second)
var runs int32
workflowFn := func(ctx workflow.Context) error {
workflow.SideEffect(ctx, func(ctx workflow.Context) any {
atomic.AddInt32(&runs, 1)
return 0
})
return nil
}
s.worker.RegisterWorkflowWithOptions(workflowFn, workflow.RegisterOptions{Name: wt})
// create 10 copies of the schedule
for i := 0; i < 10; i++ {
schedule := &schedulepb.Schedule{
Spec: &schedulepb.ScheduleSpec{
Interval: []*schedulepb.IntervalSpec{
{Interval: durationpb.New(1 * time.Second)},
},
},
Action: &schedulepb.ScheduleAction{
Action: &schedulepb.ScheduleAction_StartWorkflow{
StartWorkflow: &workflowpb.NewWorkflowExecutionInfo{
WorkflowId: fmt.Sprintf(wid, i),
WorkflowType: &commonpb.WorkflowType{Name: wt},
TaskQueue: &taskqueuepb.TaskQueue{Name: s.taskQueue, Kind: enumspb.TASK_QUEUE_KIND_NORMAL},
},
},
},
}
_, err := s.engine.CreateSchedule(NewContext(), &workflowservice.CreateScheduleRequest{
Namespace: s.namespace,
ScheduleId: fmt.Sprintf(sid, i),
Schedule: schedule,
Identity: "test",
RequestId: uuid.New(),
})
s.NoError(err)
}
time.Sleep(5 * time.Second)
// With no rate limit, we'd see 10/second == 50 workflows run. With a limit of 1/sec, we
// expect to see around 5.
s.Less(atomic.LoadInt32(&runs), int32(10))
// clean up
for i := 0; i < 10; i++ {
_, err := s.engine.DeleteSchedule(NewContext(), &workflowservice.DeleteScheduleRequest{
Namespace: s.namespace,
ScheduleId: fmt.Sprintf(sid, i),
Identity: "test",
})
s.NoError(err)
}
s.testCluster.host.dcClient.RemoveOverride(dynamicconfig.SchedulerNamespaceStartWorkflowRPS)
s.testCluster.host.dcClient.OverrideValue(s.T(), dynamicconfig.WorkerPerNamespaceWorkerCount, 0)
s.testCluster.host.workerService.RefreshPerNSWorkerManager()
time.Sleep(2 * time.Second)
s.testCluster.host.dcClient.RemoveOverride(dynamicconfig.WorkerPerNamespaceWorkerCount)
s.testCluster.host.workerService.RefreshPerNSWorkerManager()
}
func (s *ScheduleFunctionalSuite) TestNextTimeCache() {
sid := "sched-test-next-time-cache"
wid := "sched-test-next-time-cache-wf"
wt := "sched-test-next-time-cache-wt"
schedule := &schedulepb.Schedule{
Spec: &schedulepb.ScheduleSpec{
Interval: []*schedulepb.IntervalSpec{
{Interval: durationpb.New(1 * time.Second)},
},
},
Action: &schedulepb.ScheduleAction{
Action: &schedulepb.ScheduleAction_StartWorkflow{
StartWorkflow: &workflowpb.NewWorkflowExecutionInfo{
WorkflowId: wid,
WorkflowType: &commonpb.WorkflowType{Name: wt},
TaskQueue: &taskqueuepb.TaskQueue{Name: s.taskQueue, Kind: enumspb.TASK_QUEUE_KIND_NORMAL},
},
},
},
}
req := &workflowservice.CreateScheduleRequest{
Namespace: s.namespace,
ScheduleId: sid,
Schedule: schedule,
Identity: "test",
RequestId: uuid.New(),
}
var runs atomic.Int32
workflowFn := func(ctx workflow.Context) error {
workflow.SideEffect(ctx, func(ctx workflow.Context) any {
runs.Add(1)
return 0
})
return nil
}
s.worker.RegisterWorkflowWithOptions(workflowFn, workflow.RegisterOptions{Name: wt})
_, err := s.engine.CreateSchedule(NewContext(), req)
s.NoError(err)
// wait for at least 13 runs
const count = 13
s.Eventually(func() bool { return runs.Load() >= count }, (count+5)*time.Second, 500*time.Millisecond)
// there should be only four side effects for 13 runs, and only two mentioning "Next"
// (cache refills)
events := s.getHistory(s.namespace, &commonpb.WorkflowExecution{WorkflowId: scheduler.WorkflowIDPrefix + sid})
var sideEffects, nextTimeSideEffects int
for _, e := range events {
if marker := e.GetMarkerRecordedEventAttributes(); marker.GetMarkerName() == "SideEffect" {
sideEffects++
if p, ok := marker.Details["data"]; ok && len(p.Payloads) == 1 {
if string(p.Payloads[0].Metadata["messageType"]) == "temporal.server.api.schedule.v1.NextTimeCache" ||
strings.Contains(payloads.ToString(p), `"Next"`) {
nextTimeSideEffects++
}
}
}
}
const (
// These match the ones in the scheduler workflow, but they're not exported.
// Change these if those change.
FutureActionCountForList = 5
NextTimeCacheV2Size = 14
// Calculate expected results
expectedCacheSize = NextTimeCacheV2Size - FutureActionCountForList + 1
expectedRefills = (count + expectedCacheSize - 1) / expectedCacheSize
uuidCacheRefills = (count + 9) / 10
)
s.Equal(expectedRefills+uuidCacheRefills, sideEffects)
s.Equal(expectedRefills, nextTimeSideEffects)
// cleanup
_, err = s.engine.DeleteSchedule(NewContext(), &workflowservice.DeleteScheduleRequest{
Namespace: s.namespace,
ScheduleId: sid,
Identity: "test",
})
s.NoError(err)
}