mirror of
https://github.com/hanzoai/tasks.git
synced 2026-10-04 02:04:03 +00:00
1085 lines
39 KiB
Go
1085 lines
39 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 scheduler
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gogo/protobuf/jsonpb"
|
|
"github.com/gogo/protobuf/proto"
|
|
"github.com/google/uuid"
|
|
"golang.org/x/exp/slices"
|
|
|
|
commonpb "go.temporal.io/api/common/v1"
|
|
enumspb "go.temporal.io/api/enums/v1"
|
|
schedpb "go.temporal.io/api/schedule/v1"
|
|
workflowpb "go.temporal.io/api/workflow/v1"
|
|
"go.temporal.io/api/workflowservice/v1"
|
|
sdkclient "go.temporal.io/sdk/client"
|
|
sdklog "go.temporal.io/sdk/log"
|
|
"go.temporal.io/sdk/temporal"
|
|
"go.temporal.io/sdk/workflow"
|
|
|
|
schedspb "go.temporal.io/server/api/schedule/v1"
|
|
"go.temporal.io/server/common/metrics"
|
|
"go.temporal.io/server/common/payload"
|
|
"go.temporal.io/server/common/primitives/timestamp"
|
|
"go.temporal.io/server/common/searchattribute"
|
|
"go.temporal.io/server/common/util"
|
|
)
|
|
|
|
type SchedulerWorkflowVersion int64
|
|
|
|
const (
|
|
// represents the state before Version is introduced
|
|
InitialVersion SchedulerWorkflowVersion = iota
|
|
// skip over entire time range if paused and batch and cache getNextTime queries
|
|
BatchAndCacheTimeQueries
|
|
)
|
|
|
|
const (
|
|
// Schedules are implemented by a workflow whose ID is this string plus the schedule ID.
|
|
WorkflowIDPrefix = "temporal-sys-scheduler:"
|
|
|
|
// This is an example of a timestamp that's appended to the workflow
|
|
// id, used for validation in the frontend.
|
|
AppendedTimestampForValidation = "-2009-11-10T23:00:00Z"
|
|
|
|
SignalNameUpdate = "update"
|
|
SignalNamePatch = "patch"
|
|
SignalNameRefresh = "refresh"
|
|
|
|
QueryNameDescribe = "describe"
|
|
QueryNameListMatchingTimes = "listMatchingTimes"
|
|
|
|
MemoFieldInfo = "ScheduleInfo"
|
|
|
|
InitialConflictToken = 1
|
|
|
|
// Maximum number of times to list per ListMatchingTimes query. (This is used only in a
|
|
// query so it can be changed without breaking history.)
|
|
maxListMatchingTimesCount = 1000
|
|
|
|
rateLimitedErrorType = "RateLimited"
|
|
|
|
maxNextTimeResultCacheSize = 10
|
|
)
|
|
|
|
type (
|
|
scheduler struct {
|
|
schedspb.StartScheduleArgs
|
|
|
|
ctx workflow.Context
|
|
a *activities
|
|
logger sdklog.Logger
|
|
metrics sdkclient.MetricsHandler
|
|
|
|
cspec *CompiledSpec
|
|
|
|
tweakables tweakablePolicies
|
|
|
|
currentTimer workflow.Future
|
|
currentTimerDeadline time.Time
|
|
|
|
// We might have zero or one long-poll watcher activity running. If so, these are set:
|
|
watchingWorkflowId string
|
|
watchingFuture workflow.Future
|
|
|
|
// Signal requests
|
|
pendingPatch *schedpb.SchedulePatch
|
|
pendingUpdate *schedspb.FullUpdateRequest
|
|
|
|
uuidBatch []string
|
|
|
|
// This cache is used to store time results after batching getNextTime queries
|
|
// in a single SideEffect
|
|
nextTimeResultCache map[time.Time]getNextTimeResult
|
|
}
|
|
|
|
tweakablePolicies struct {
|
|
DefaultCatchupWindow time.Duration // Default for catchup window
|
|
MinCatchupWindow time.Duration // Minimum for catchup window
|
|
CanceledTerminatedCountAsFailures bool // Whether cancelled+terminated count for pause-on-failure
|
|
AlwaysAppendTimestamp bool // Whether to append timestamp for non-overlapping workflows too
|
|
FutureActionCount int // The number of future action times to include in Describe.
|
|
RecentActionCount int // The number of recent actual action results to include in Describe.
|
|
FutureActionCountForList int // The number of future action times to include in List (search attr).
|
|
RecentActionCountForList int // The number of recent actual action results to include in List (search attr).
|
|
IterationsBeforeContinueAsNew int
|
|
SleepWhilePaused bool // If true, don't set timers while paused/out of actions
|
|
// MaxBufferSize limits the number of buffered starts. This also limits the number of
|
|
// workflows that can be backfilled at once (since they all have to fit in the buffer).
|
|
MaxBufferSize int
|
|
AllowZeroSleep bool // Whether to allow a zero-length timer. Used for workflow compatibility.
|
|
ReuseTimer bool // Whether to reuse timer. Used for workflow compatibility.
|
|
Version SchedulerWorkflowVersion // Used to keep track of schedules version to release new features and for backward compatibility
|
|
// version 0 corresponds to the schedule version that comes before introducing the Version parameter
|
|
}
|
|
)
|
|
|
|
var (
|
|
defaultLocalActivityOptions = workflow.LocalActivityOptions{
|
|
// This applies to watch, cancel, and terminate. Start workflow overrides this.
|
|
ScheduleToCloseTimeout: 1 * time.Hour,
|
|
// Each local activity is one or a few local RPCs.
|
|
// Currently this is applied manually, see https://github.com/temporalio/sdk-go/issues/1066
|
|
StartToCloseTimeout: 5 * time.Second,
|
|
RetryPolicy: &temporal.RetryPolicy{
|
|
InitialInterval: 1 * time.Second,
|
|
MaximumInterval: 60 * time.Second,
|
|
},
|
|
}
|
|
|
|
// We put a handful of options in a static value and use it as a MutableSideEffect within
|
|
// the workflow so that we can change them without breaking existing executions or having
|
|
// to use versioning.
|
|
currentTweakablePolicies = tweakablePolicies{
|
|
DefaultCatchupWindow: 365 * 24 * time.Hour,
|
|
MinCatchupWindow: 10 * time.Second,
|
|
CanceledTerminatedCountAsFailures: false,
|
|
AlwaysAppendTimestamp: true,
|
|
FutureActionCount: 10,
|
|
RecentActionCount: 10,
|
|
FutureActionCountForList: 5,
|
|
RecentActionCountForList: 5,
|
|
IterationsBeforeContinueAsNew: 500,
|
|
SleepWhilePaused: true,
|
|
MaxBufferSize: 1000,
|
|
AllowZeroSleep: true,
|
|
ReuseTimer: true,
|
|
Version: BatchAndCacheTimeQueries,
|
|
}
|
|
|
|
errUpdateConflict = errors.New("conflicting concurrent update")
|
|
)
|
|
|
|
func SchedulerWorkflow(ctx workflow.Context, args *schedspb.StartScheduleArgs) error {
|
|
scheduler := &scheduler{
|
|
StartScheduleArgs: *args,
|
|
ctx: ctx,
|
|
a: nil,
|
|
logger: sdklog.With(workflow.GetLogger(ctx), "wf-namespace", args.State.Namespace, "schedule-id", args.State.ScheduleId),
|
|
metrics: workflow.GetMetricsHandler(ctx).WithTags(map[string]string{"namespace": args.State.Namespace}),
|
|
}
|
|
return scheduler.run()
|
|
}
|
|
|
|
func (s *scheduler) run() error {
|
|
s.updateTweakables()
|
|
s.ensureFields()
|
|
s.compileSpec()
|
|
|
|
if err := workflow.SetQueryHandler(s.ctx, QueryNameDescribe, s.handleDescribeQuery); err != nil {
|
|
return err
|
|
}
|
|
if err := workflow.SetQueryHandler(s.ctx, QueryNameListMatchingTimes, s.handleListMatchingTimesQuery); err != nil {
|
|
return err
|
|
}
|
|
|
|
if s.State.LastProcessedTime == nil {
|
|
// log these as json since it's more readable than the Go representation
|
|
var m jsonpb.Marshaler
|
|
var specJson, policiesJson strings.Builder
|
|
_ = m.Marshal(&specJson, s.Schedule.Spec)
|
|
_ = m.Marshal(&policiesJson, s.Schedule.Policies)
|
|
s.logger.Info("Starting schedule", "spec", specJson.String(), "policies", policiesJson.String())
|
|
|
|
s.State.LastProcessedTime = timestamp.TimePtr(s.now())
|
|
s.State.ConflictToken = InitialConflictToken
|
|
s.Info.CreateTime = s.State.LastProcessedTime
|
|
}
|
|
|
|
// A schedule may be created with an initial Patch, e.g. start one immediately. Put that in
|
|
// the state so it takes effect below.
|
|
s.pendingPatch = s.InitialPatch
|
|
s.InitialPatch = nil
|
|
|
|
for iters := s.tweakables.IterationsBeforeContinueAsNew; iters > 0 || s.pendingUpdate != nil || s.pendingPatch != nil; iters-- {
|
|
|
|
t1 := timestamp.TimeValue(s.State.LastProcessedTime)
|
|
t2 := s.now()
|
|
if t2.Before(t1) {
|
|
// Time went backwards. Currently this can only happen across a continue-as-new boundary.
|
|
s.logger.Warn("Time went backwards", "from", t1, "to", t2)
|
|
t2 = t1
|
|
}
|
|
nextWakeup := s.processTimeRange(
|
|
t1, t2,
|
|
// resolve this to the schedule's policy as late as possible
|
|
enumspb.SCHEDULE_OVERLAP_POLICY_UNSPECIFIED,
|
|
false,
|
|
)
|
|
s.State.LastProcessedTime = timestamp.TimePtr(t2)
|
|
// handle signals after processing time range that just elapsed
|
|
scheduleChanged := s.processSignals()
|
|
if scheduleChanged {
|
|
// need to calculate sleep again
|
|
nextWakeup = s.processTimeRange(t2, t2, enumspb.SCHEDULE_OVERLAP_POLICY_UNSPECIFIED, false)
|
|
}
|
|
// try starting workflows in the buffer
|
|
//nolint:revive
|
|
for s.processBuffer() {
|
|
}
|
|
s.updateMemoAndSearchAttributes()
|
|
// sleep returns on any of:
|
|
// 1. requested time elapsed
|
|
// 2. we got a signal (update, request, refresh)
|
|
// 3. a workflow that we were watching finished
|
|
s.sleep(nextWakeup)
|
|
s.updateTweakables()
|
|
}
|
|
|
|
// Any watcher activities will get cancelled automatically if running.
|
|
|
|
s.logger.Debug("Schedule doing continue-as-new")
|
|
return workflow.NewContinueAsNewError(s.ctx, WorkflowType, &s.StartScheduleArgs)
|
|
}
|
|
|
|
func (s *scheduler) ensureFields() {
|
|
if s.Schedule == nil {
|
|
s.Schedule = &schedpb.Schedule{}
|
|
}
|
|
if s.Schedule.Spec == nil {
|
|
s.Schedule.Spec = &schedpb.ScheduleSpec{}
|
|
}
|
|
if s.Schedule.Action == nil {
|
|
s.Schedule.Action = &schedpb.ScheduleAction{}
|
|
}
|
|
if s.Schedule.Policies == nil {
|
|
s.Schedule.Policies = &schedpb.SchedulePolicies{}
|
|
}
|
|
|
|
// set defaults eagerly so they show up in describe output
|
|
s.Schedule.Policies.OverlapPolicy = s.resolveOverlapPolicy(s.Schedule.Policies.OverlapPolicy)
|
|
s.Schedule.Policies.CatchupWindow = timestamp.DurationPtr(s.getCatchupWindow())
|
|
|
|
if s.Schedule.State == nil {
|
|
s.Schedule.State = &schedpb.ScheduleState{}
|
|
}
|
|
if s.Info == nil {
|
|
s.Info = &schedpb.ScheduleInfo{}
|
|
}
|
|
if s.State == nil {
|
|
s.State = &schedspb.InternalState{}
|
|
}
|
|
}
|
|
|
|
func (s *scheduler) compileSpec() {
|
|
// if spec changes invalidate current nextTimeResult cache
|
|
s.nextTimeResultCache = nil
|
|
|
|
cspec, err := NewCompiledSpec(s.Schedule.Spec)
|
|
if err != nil {
|
|
if s.logger != nil {
|
|
s.logger.Error("Invalid schedule", "error", err)
|
|
}
|
|
s.Info.InvalidScheduleError = err.Error()
|
|
s.cspec = nil
|
|
} else {
|
|
s.Info.InvalidScheduleError = ""
|
|
s.cspec = cspec
|
|
}
|
|
}
|
|
|
|
func (s *scheduler) now() time.Time {
|
|
// Notes:
|
|
// 1. The time returned here is actually the timestamp of the WorkflowTaskStarted
|
|
// event, which is generated in history, not any time on the worker itself.
|
|
// 2. There will be some delay between when history stamps the time on the
|
|
// WorkflowTaskStarted event and when this code runs, as the event+task goes through
|
|
// matching and frontend. But it should be well under a second, which is our minimum
|
|
// granularity anyway.
|
|
// 3. It's actually the maximum of all of those events: the go sdk enforces that
|
|
// workflow time is monotonic. So if the clock on a history node is wrong and workflow
|
|
// time is ahead of real time for a while, and then goes back, scheduled jobs will run
|
|
// ahead of time, and then nothing will happen until real time catches up to where it
|
|
// was temporarily ahead. Currently the only way to "recover" from this situation is
|
|
// to recreate the schedule/scheduler workflow.
|
|
// 4. Actually there is one way time can appear to go backwards from the point of view
|
|
// of this workflow: across a continue-as-new, since monotonicity isn't preserved
|
|
// there (as far as I know). We'll treat that the same since we keep track of the last
|
|
// processed schedule time.
|
|
return workflow.Now(s.ctx)
|
|
}
|
|
|
|
func (s *scheduler) processPatch(patch *schedpb.SchedulePatch) {
|
|
s.logger.Debug("Schedule patch", "patch", patch.String())
|
|
|
|
if trigger := patch.TriggerImmediately; trigger != nil {
|
|
now := s.now()
|
|
s.addStart(now, now, trigger.OverlapPolicy, true)
|
|
}
|
|
|
|
for _, bfr := range patch.BackfillRequest {
|
|
s.processTimeRange(
|
|
timestamp.TimeValue(bfr.GetStartTime()),
|
|
timestamp.TimeValue(bfr.GetEndTime()),
|
|
bfr.GetOverlapPolicy(),
|
|
true,
|
|
)
|
|
}
|
|
|
|
if patch.Pause != "" {
|
|
s.Schedule.State.Paused = true
|
|
s.Schedule.State.Notes = patch.Pause
|
|
s.incSeqNo()
|
|
}
|
|
if patch.Unpause != "" {
|
|
s.Schedule.State.Paused = false
|
|
s.Schedule.State.Notes = patch.Unpause
|
|
s.incSeqNo()
|
|
}
|
|
}
|
|
|
|
func (s *scheduler) getNextTime(after time.Time) getNextTimeResult {
|
|
|
|
// we populate the map sequentially, if after is not in the map, it means we either exhausted
|
|
// all items, or we jumped through time (forward or backward), in either case, refresh the cache
|
|
next, ok := s.nextTimeResultCache[after]
|
|
if ok {
|
|
return next
|
|
}
|
|
s.nextTimeResultCache = nil
|
|
// Run this logic in a SideEffect so that we can fix bugs there without breaking
|
|
// existing schedule workflows.
|
|
panicIfErr(workflow.SideEffect(s.ctx, func(ctx workflow.Context) interface{} {
|
|
results := make(map[time.Time]getNextTimeResult)
|
|
for t := after; !t.IsZero() && len(results) < maxNextTimeResultCacheSize; {
|
|
next := s.cspec.getNextTime(t)
|
|
results[t] = next
|
|
t = next.Next
|
|
}
|
|
return results
|
|
}).Get(&s.nextTimeResultCache))
|
|
return s.nextTimeResultCache[after]
|
|
}
|
|
|
|
func (s *scheduler) processTimeRange(
|
|
t1, t2 time.Time,
|
|
overlapPolicy enumspb.ScheduleOverlapPolicy,
|
|
manual bool,
|
|
) time.Time {
|
|
s.logger.Debug("processTimeRange", "t1", t1, "t2", t2, "overlap-policy", overlapPolicy, "manual", manual)
|
|
|
|
if s.cspec == nil {
|
|
return time.Time{}
|
|
}
|
|
|
|
catchupWindow := s.getCatchupWindow()
|
|
|
|
// A previous version would record a marker for each time which could make a workflow
|
|
// fail. With the new version, the entire time range is skipped if the workflow is paused
|
|
// or we are not going to take an action now
|
|
if s.tweakables.Version >= BatchAndCacheTimeQueries {
|
|
// Peek at paused/remaining actions state and don't bother if we're not going to
|
|
// take an action now. (Don't count as missed catchup window either.)
|
|
// Skip over entire time range if paused or no actions can be taken
|
|
if !s.canTakeScheduledAction(manual, false) {
|
|
return s.getNextTime(t2).Next
|
|
}
|
|
}
|
|
|
|
for {
|
|
var next getNextTimeResult
|
|
if s.tweakables.Version < BatchAndCacheTimeQueries {
|
|
// Run this logic in a SideEffect so that we can fix bugs there without breaking
|
|
// existing schedule workflows.
|
|
panicIfErr(workflow.SideEffect(s.ctx, func(ctx workflow.Context) interface{} {
|
|
return s.cspec.getNextTime(t1)
|
|
}).Get(&next))
|
|
} else {
|
|
next = s.getNextTime(t1)
|
|
}
|
|
t1 = next.Next
|
|
if t1.IsZero() || t1.After(t2) {
|
|
return t1
|
|
}
|
|
if s.tweakables.Version < BatchAndCacheTimeQueries && !s.canTakeScheduledAction(manual, false) {
|
|
continue
|
|
}
|
|
if !manual && t2.Sub(t1) > catchupWindow {
|
|
s.logger.Warn("Schedule missed catchup window", "now", t2, "time", t1)
|
|
s.metrics.Counter(metrics.ScheduleMissedCatchupWindow.GetMetricName()).Inc(1)
|
|
s.Info.MissedCatchupWindow++
|
|
continue
|
|
}
|
|
s.addStart(next.Nominal, next.Next, overlapPolicy, manual)
|
|
}
|
|
}
|
|
|
|
func (s *scheduler) canTakeScheduledAction(manual, decrement bool) bool {
|
|
// If manual (trigger immediately or backfill), always allow
|
|
if manual {
|
|
return true
|
|
}
|
|
// If paused, don't do anything
|
|
if s.Schedule.State.Paused {
|
|
return false
|
|
}
|
|
// If unlimited actions, allow
|
|
if !s.Schedule.State.LimitedActions {
|
|
return true
|
|
}
|
|
// Otherwise check and decrement limit
|
|
if s.Schedule.State.RemainingActions > 0 {
|
|
if decrement {
|
|
s.Schedule.State.RemainingActions--
|
|
s.incSeqNo()
|
|
}
|
|
return true
|
|
}
|
|
// No actions left
|
|
return false
|
|
}
|
|
|
|
func (s *scheduler) sleep(nextWakeup time.Time) {
|
|
sel := workflow.NewSelector(s.ctx)
|
|
|
|
upCh := workflow.GetSignalChannel(s.ctx, SignalNameUpdate)
|
|
sel.AddReceive(upCh, func(ch workflow.ReceiveChannel, _ bool) {
|
|
ch.Receive(s.ctx, &s.pendingUpdate)
|
|
})
|
|
|
|
reqCh := workflow.GetSignalChannel(s.ctx, SignalNamePatch)
|
|
sel.AddReceive(reqCh, func(ch workflow.ReceiveChannel, _ bool) {
|
|
ch.Receive(s.ctx, &s.pendingPatch)
|
|
})
|
|
|
|
refreshCh := workflow.GetSignalChannel(s.ctx, SignalNameRefresh)
|
|
sel.AddReceive(refreshCh, s.handleRefreshSignal)
|
|
|
|
// if we're paused or out of actions, we don't need to wake up until we get an update
|
|
if s.tweakables.SleepWhilePaused && !s.canTakeScheduledAction(false, false) {
|
|
nextWakeup = time.Time{}
|
|
}
|
|
|
|
if !nextWakeup.IsZero() {
|
|
sleepTime := nextWakeup.Sub(s.now())
|
|
// A previous version of this workflow passed around sleep duration instead of wakeup time,
|
|
// which means it always set a timer even in cases where sleepTime comes out negative. For
|
|
// compatibility, we have to continue setting a positive timer in those cases. The value
|
|
// doesn't have to match, though.
|
|
if !s.tweakables.AllowZeroSleep && sleepTime <= 0 {
|
|
sleepTime = time.Second
|
|
}
|
|
// A previous version of this workflow always created a new timer here, which is wasteful.
|
|
// We can reuse a previous timer if we have the same deadline and it didn't fire yet.
|
|
if s.tweakables.ReuseTimer {
|
|
if s.currentTimer == nil || !s.currentTimerDeadline.Equal(nextWakeup) {
|
|
s.currentTimer = workflow.NewTimer(s.ctx, sleepTime)
|
|
s.currentTimerDeadline = nextWakeup
|
|
}
|
|
sel.AddFuture(s.currentTimer, func(_ workflow.Future) {
|
|
s.currentTimer = nil
|
|
})
|
|
} else {
|
|
tmr := workflow.NewTimer(s.ctx, sleepTime)
|
|
sel.AddFuture(tmr, func(_ workflow.Future) {})
|
|
}
|
|
}
|
|
|
|
if s.watchingFuture != nil {
|
|
sel.AddFuture(s.watchingFuture, s.wfWatcherReturned)
|
|
}
|
|
|
|
s.logger.Debug("sleeping", "next-wakeup", nextWakeup, "watching", s.watchingFuture != nil)
|
|
sel.Select(s.ctx)
|
|
for sel.HasPending() {
|
|
sel.Select(s.ctx)
|
|
}
|
|
}
|
|
|
|
func (s *scheduler) wfWatcherReturned(f workflow.Future) {
|
|
id := s.watchingWorkflowId
|
|
s.watchingWorkflowId = ""
|
|
s.watchingFuture = nil
|
|
s.processWatcherResult(id, f)
|
|
}
|
|
|
|
func (s *scheduler) processWatcherResult(id string, f workflow.Future) {
|
|
var res schedspb.WatchWorkflowResponse
|
|
err := f.Get(s.ctx, &res)
|
|
if err != nil {
|
|
s.logger.Error("error from workflow watcher future", "workflow", id, "error", err)
|
|
return
|
|
}
|
|
|
|
if res.Status == enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING {
|
|
// this should only happen for a refresh, not a long-poll
|
|
s.logger.Debug("watcher returned for running workflow")
|
|
return
|
|
}
|
|
|
|
// now we know it's not running, remove from running workflow list
|
|
match := func(ex *commonpb.WorkflowExecution) bool { return ex.WorkflowId == id }
|
|
if idx := slices.IndexFunc(s.Info.RunningWorkflows, match); idx >= 0 {
|
|
s.Info.RunningWorkflows = slices.Delete(s.Info.RunningWorkflows, idx, idx+1)
|
|
} else {
|
|
s.logger.Error("closed workflow not found in running list", "workflow", id)
|
|
}
|
|
|
|
// handle pause-on-failure
|
|
failedStatus := res.Status == enumspb.WORKFLOW_EXECUTION_STATUS_FAILED ||
|
|
res.Status == enumspb.WORKFLOW_EXECUTION_STATUS_TIMED_OUT ||
|
|
(s.tweakables.CanceledTerminatedCountAsFailures &&
|
|
(res.Status == enumspb.WORKFLOW_EXECUTION_STATUS_CANCELED || res.Status == enumspb.WORKFLOW_EXECUTION_STATUS_TERMINATED))
|
|
pauseOnFailure := s.Schedule.Policies.PauseOnFailure && failedStatus && !s.Schedule.State.Paused
|
|
if pauseOnFailure {
|
|
s.Schedule.State.Paused = true
|
|
if res.Status == enumspb.WORKFLOW_EXECUTION_STATUS_FAILED {
|
|
s.Schedule.State.Notes = fmt.Sprintf("paused due to workflow failure: %s: %s", id, res.GetFailure().GetMessage())
|
|
s.logger.Debug("paused due to workflow failure", "workflow", id, "message", res.GetFailure().GetMessage())
|
|
} else if res.Status == enumspb.WORKFLOW_EXECUTION_STATUS_TIMED_OUT {
|
|
s.Schedule.State.Notes = fmt.Sprintf("paused due to workflow timeout: %s", id)
|
|
s.logger.Debug("paused due to workflow timeout", "workflow", id)
|
|
}
|
|
s.incSeqNo()
|
|
}
|
|
|
|
// handle last completion/failure
|
|
if res.GetResult() != nil {
|
|
s.State.LastCompletionResult = res.GetResult()
|
|
s.State.ContinuedFailure = nil
|
|
} else if res.GetFailure() != nil {
|
|
// leave LastCompletionResult from previous run
|
|
s.State.ContinuedFailure = res.GetFailure()
|
|
}
|
|
|
|
s.logger.Debug("started workflow finished", "workflow", id, "status", res.Status, "pause-after-failure", pauseOnFailure)
|
|
}
|
|
|
|
func (s *scheduler) processUpdate(req *schedspb.FullUpdateRequest) {
|
|
if err := s.checkConflict(req.ConflictToken); err != nil {
|
|
s.logger.Warn("Update conflicted with concurrent change")
|
|
return
|
|
}
|
|
|
|
s.logger.Debug("Schedule update", "new-schedule", req.Schedule.String())
|
|
|
|
s.Schedule.Spec = req.Schedule.GetSpec()
|
|
s.Schedule.Action = req.Schedule.GetAction()
|
|
s.Schedule.Policies = req.Schedule.GetPolicies()
|
|
s.Schedule.State = req.Schedule.GetState()
|
|
// don't touch Info
|
|
|
|
s.ensureFields()
|
|
s.compileSpec()
|
|
|
|
s.Info.UpdateTime = timestamp.TimePtr(s.now())
|
|
s.incSeqNo()
|
|
}
|
|
|
|
func (s *scheduler) handleRefreshSignal(ch workflow.ReceiveChannel, _ bool) {
|
|
ch.Receive(s.ctx, nil)
|
|
s.logger.Debug("got refresh signal")
|
|
// If we're woken up by any signal, we'll pass through processBuffer before sleeping again.
|
|
// processBuffer will see this flag and refresh everything.
|
|
s.State.NeedRefresh = true
|
|
}
|
|
|
|
func (s *scheduler) processSignals() bool {
|
|
scheduleChanged := false
|
|
if s.pendingPatch != nil {
|
|
s.processPatch(s.pendingPatch)
|
|
s.pendingPatch = nil
|
|
}
|
|
if s.pendingUpdate != nil {
|
|
s.processUpdate(s.pendingUpdate)
|
|
s.pendingUpdate = nil
|
|
scheduleChanged = true
|
|
}
|
|
return scheduleChanged
|
|
}
|
|
|
|
func (s *scheduler) getFutureActionTimes(n int) []*time.Time {
|
|
// Note that `s` may be a fake scheduler used to compute list info at creation time.
|
|
|
|
if s.cspec == nil {
|
|
return nil
|
|
}
|
|
out := make([]*time.Time, 0, n)
|
|
t1 := timestamp.TimeValue(s.State.LastProcessedTime)
|
|
for len(out) < n {
|
|
// don't need to call getNextTime in SideEffect because this is only used in a query
|
|
// handler and for the UpsertMemo value
|
|
t1 = s.cspec.getNextTime(t1).Next
|
|
if t1.IsZero() {
|
|
break
|
|
}
|
|
out = append(out, timestamp.TimePtr(t1))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *scheduler) handleDescribeQuery() (*schedspb.DescribeResponse, error) {
|
|
// this is a query handler, don't modify s.Info directly
|
|
infoCopy := *s.Info
|
|
infoCopy.FutureActionTimes = s.getFutureActionTimes(s.tweakables.FutureActionCount)
|
|
|
|
return &schedspb.DescribeResponse{
|
|
Schedule: s.Schedule,
|
|
Info: &infoCopy,
|
|
ConflictToken: s.State.ConflictToken,
|
|
}, nil
|
|
}
|
|
|
|
func (s *scheduler) handleListMatchingTimesQuery(req *workflowservice.ListScheduleMatchingTimesRequest) (*workflowservice.ListScheduleMatchingTimesResponse, error) {
|
|
if req == nil || req.StartTime == nil || req.EndTime == nil {
|
|
return nil, errors.New("missing or invalid query")
|
|
}
|
|
if s.cspec == nil {
|
|
return nil, errors.New("invalid schedule: " + s.Info.InvalidScheduleError)
|
|
}
|
|
|
|
var out []*time.Time
|
|
t1 := timestamp.TimeValue(req.StartTime)
|
|
for i := 0; i < maxListMatchingTimesCount; i++ {
|
|
// don't need to call getNextTime in SideEffect because this is just a query
|
|
t1 = s.cspec.getNextTime(t1).Next
|
|
if t1.IsZero() || t1.After(timestamp.TimeValue(req.EndTime)) {
|
|
break
|
|
}
|
|
out = append(out, timestamp.TimePtr(t1))
|
|
}
|
|
return &workflowservice.ListScheduleMatchingTimesResponse{StartTime: out}, nil
|
|
}
|
|
|
|
func (s *scheduler) incSeqNo() {
|
|
s.State.ConflictToken++
|
|
}
|
|
|
|
func (s *scheduler) getListInfo() *schedpb.ScheduleListInfo {
|
|
// Note that `s` may be a fake scheduler used to compute list info at creation time, before
|
|
// the first workflow task. This function and anything it calls should not use s.ctx.
|
|
|
|
// make shallow copy
|
|
spec := *s.Schedule.Spec
|
|
// clear fields that are too large/not useful for the list view
|
|
spec.TimezoneData = nil
|
|
|
|
return &schedpb.ScheduleListInfo{
|
|
Spec: &spec,
|
|
WorkflowType: s.Schedule.Action.GetStartWorkflow().GetWorkflowType(),
|
|
Notes: s.Schedule.State.Notes,
|
|
Paused: s.Schedule.State.Paused,
|
|
RecentActions: util.SliceTail(s.Info.RecentActions, s.tweakables.RecentActionCountForList),
|
|
FutureActionTimes: s.getFutureActionTimes(s.tweakables.FutureActionCountForList),
|
|
}
|
|
}
|
|
|
|
func (s *scheduler) updateMemoAndSearchAttributes() {
|
|
newInfo := s.getListInfo()
|
|
|
|
workflowInfo := workflow.GetInfo(s.ctx)
|
|
currentInfoPayload := workflowInfo.Memo.GetFields()[MemoFieldInfo]
|
|
|
|
var currentInfoBytes []byte
|
|
var currentInfo schedpb.ScheduleListInfo
|
|
|
|
if currentInfoPayload == nil ||
|
|
payload.Decode(currentInfoPayload, ¤tInfoBytes) != nil ||
|
|
currentInfo.Unmarshal(currentInfoBytes) != nil ||
|
|
!proto.Equal(¤tInfo, newInfo) {
|
|
// marshal manually to get proto encoding (default dataconverter will use json)
|
|
newInfoBytes, err := newInfo.Marshal()
|
|
if err == nil {
|
|
err = workflow.UpsertMemo(s.ctx, map[string]interface{}{
|
|
MemoFieldInfo: newInfoBytes,
|
|
})
|
|
}
|
|
if err != nil {
|
|
s.logger.Error("error updating memo", "error", err)
|
|
}
|
|
}
|
|
|
|
currentPausedPayload := workflowInfo.SearchAttributes.GetIndexedFields()[searchattribute.TemporalSchedulePaused]
|
|
var currentPaused bool
|
|
if currentPausedPayload == nil ||
|
|
payload.Decode(currentPausedPayload, ¤tPaused) != nil ||
|
|
currentPaused != s.Schedule.State.Paused {
|
|
err := workflow.UpsertSearchAttributes(s.ctx, map[string]interface{}{
|
|
searchattribute.TemporalSchedulePaused: s.Schedule.State.Paused,
|
|
})
|
|
if err != nil {
|
|
s.logger.Error("error updating search attributes", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *scheduler) checkConflict(token int64) error {
|
|
if token == 0 || token == s.State.ConflictToken {
|
|
return nil
|
|
}
|
|
return errUpdateConflict
|
|
}
|
|
|
|
func (s *scheduler) updateTweakables() {
|
|
// Use MutableSideEffect so that we can change the defaults without breaking determinism.
|
|
get := func(ctx workflow.Context) interface{} { return currentTweakablePolicies }
|
|
eq := func(a, b interface{}) bool { return a.(tweakablePolicies) == b.(tweakablePolicies) }
|
|
if err := workflow.MutableSideEffect(s.ctx, "tweakables", get, eq).Get(&s.tweakables); err != nil {
|
|
panic("can't decode tweakablePolicies:" + err.Error())
|
|
}
|
|
}
|
|
|
|
func (s *scheduler) getCatchupWindow() time.Duration {
|
|
cw := s.Schedule.Policies.CatchupWindow
|
|
if cw == nil {
|
|
return s.tweakables.DefaultCatchupWindow
|
|
} else if *cw < s.tweakables.MinCatchupWindow {
|
|
return s.tweakables.MinCatchupWindow
|
|
} else {
|
|
return *cw
|
|
}
|
|
}
|
|
|
|
func (s *scheduler) resolveOverlapPolicy(overlapPolicy enumspb.ScheduleOverlapPolicy) enumspb.ScheduleOverlapPolicy {
|
|
if overlapPolicy == enumspb.SCHEDULE_OVERLAP_POLICY_UNSPECIFIED {
|
|
overlapPolicy = s.Schedule.Policies.OverlapPolicy
|
|
}
|
|
if overlapPolicy == enumspb.SCHEDULE_OVERLAP_POLICY_UNSPECIFIED {
|
|
overlapPolicy = enumspb.SCHEDULE_OVERLAP_POLICY_SKIP
|
|
}
|
|
return overlapPolicy
|
|
}
|
|
|
|
func (s *scheduler) addStart(nominalTime, actualTime time.Time, overlapPolicy enumspb.ScheduleOverlapPolicy, manual bool) {
|
|
s.logger.Debug("addStart", "start-time", nominalTime, "actual-start-time", actualTime, "overlap-policy", overlapPolicy, "manual", manual)
|
|
if s.tweakables.MaxBufferSize > 0 && len(s.State.BufferedStarts) >= s.tweakables.MaxBufferSize {
|
|
s.logger.Warn("Buffer too large", "start-time", nominalTime, "overlap-policy", overlapPolicy, "manual", manual)
|
|
s.metrics.Counter(metrics.ScheduleBufferOverruns.GetMetricName()).Inc(1)
|
|
return
|
|
}
|
|
s.State.BufferedStarts = append(s.State.BufferedStarts, &schedspb.BufferedStart{
|
|
NominalTime: timestamp.TimePtr(nominalTime),
|
|
ActualTime: timestamp.TimePtr(actualTime),
|
|
OverlapPolicy: overlapPolicy,
|
|
Manual: manual,
|
|
})
|
|
// we have a new start to process, so we need to make sure that we have up-to-date status
|
|
// on any workflows that we started.
|
|
s.State.NeedRefresh = true
|
|
}
|
|
|
|
// processBuffer should return true if there might be more work to do right now.
|
|
//
|
|
//nolint:revive
|
|
func (s *scheduler) processBuffer() bool {
|
|
s.logger.Debug("processBuffer", "buffer", len(s.State.BufferedStarts), "running", len(s.Info.RunningWorkflows), "need-refresh", s.State.NeedRefresh)
|
|
|
|
// TODO: consider doing this always and removing needRefresh? we only end up here without
|
|
// needRefresh in the case of update, or patch without an immediate run, so it's not much
|
|
// wasted work.
|
|
// TODO: on the other hand, we don't have to refresh if we have one workflow running and a
|
|
// long-poll watcher running, because we would have gotten woken up already.
|
|
if s.State.NeedRefresh {
|
|
s.refreshWorkflows(slices.Clone(s.Info.RunningWorkflows))
|
|
s.State.NeedRefresh = false
|
|
}
|
|
|
|
// Make sure we have something to start. If not, we can clear the buffer.
|
|
req := s.Schedule.Action.GetStartWorkflow()
|
|
if req == nil || len(s.State.BufferedStarts) == 0 {
|
|
s.State.BufferedStarts = nil
|
|
return false
|
|
}
|
|
|
|
isRunning := len(s.Info.RunningWorkflows) > 0
|
|
tryAgain := false
|
|
|
|
action := processBuffer(s.State.BufferedStarts, isRunning, s.resolveOverlapPolicy)
|
|
|
|
s.State.BufferedStarts = action.newBuffer
|
|
s.Info.OverlapSkipped += action.overlapSkipped
|
|
|
|
// Try starting whatever we're supposed to start now
|
|
allStarts := action.overlappingStarts
|
|
if action.nonOverlappingStart != nil {
|
|
allStarts = append(allStarts, action.nonOverlappingStart)
|
|
}
|
|
for _, start := range allStarts {
|
|
if !s.canTakeScheduledAction(start.Manual, true) {
|
|
// try again to drain the buffer if paused or out of actions
|
|
tryAgain = true
|
|
continue
|
|
}
|
|
result, err := s.startWorkflow(start, req)
|
|
metricsWithTag := s.metrics.WithTags(map[string]string{
|
|
metrics.ScheduleActionTypeTag: metrics.ScheduleActionStartWorkflow})
|
|
if err != nil {
|
|
s.logger.Error("Failed to start workflow", "error", err)
|
|
metricsWithTag.Counter(metrics.ScheduleActionErrors.GetMetricName()).Inc(1)
|
|
// TODO: we could put this back in the buffer and retry (after a delay) up until
|
|
// the catchup window. of course, it's unlikely that this workflow would be making
|
|
// progress while we're unable to start a new one, so maybe it's not that valuable.
|
|
tryAgain = true
|
|
continue
|
|
}
|
|
metricsWithTag.Counter(metrics.ScheduleActionSuccess.GetMetricName()).Inc(1)
|
|
s.recordAction(result)
|
|
}
|
|
|
|
// Terminate or cancel if required (terminate overrides cancel if both are present)
|
|
if action.needTerminate {
|
|
for _, ex := range s.Info.RunningWorkflows {
|
|
s.terminateWorkflow(ex)
|
|
}
|
|
} else if action.needCancel {
|
|
for _, ex := range s.Info.RunningWorkflows {
|
|
s.cancelWorkflow(ex)
|
|
}
|
|
}
|
|
|
|
// If we still have a buffer here, then we're waiting for started workflow(s) to be closed
|
|
// (maybe one we just started). In order to get woken up, we need to be watching at least
|
|
// one of them with an activity. We only need one watcher at a time, though: after that one
|
|
// returns, we'll end up back here and start the next one.
|
|
if len(s.State.BufferedStarts) > 0 && s.watchingFuture == nil {
|
|
if len(s.Info.RunningWorkflows) > 0 {
|
|
s.startLongPollWatcher(s.Info.RunningWorkflows[0])
|
|
} else {
|
|
s.logger.Error("have buffered workflows but none running")
|
|
}
|
|
}
|
|
|
|
return tryAgain
|
|
}
|
|
|
|
func (s *scheduler) recordAction(result *schedpb.ScheduleActionResult) {
|
|
s.Info.ActionCount++
|
|
s.Info.RecentActions = util.SliceTail(append(s.Info.RecentActions, result), s.tweakables.RecentActionCount)
|
|
if result.StartWorkflowResult != nil {
|
|
s.Info.RunningWorkflows = append(s.Info.RunningWorkflows, result.StartWorkflowResult)
|
|
}
|
|
}
|
|
|
|
func (s *scheduler) startWorkflow(
|
|
start *schedspb.BufferedStart,
|
|
newWorkflow *workflowpb.NewWorkflowExecutionInfo,
|
|
) (*schedpb.ScheduleActionResult, error) {
|
|
nominalTimeSec := start.NominalTime.UTC().Truncate(time.Second)
|
|
workflowID := newWorkflow.WorkflowId
|
|
if start.OverlapPolicy == enumspb.SCHEDULE_OVERLAP_POLICY_ALLOW_ALL || s.tweakables.AlwaysAppendTimestamp {
|
|
// must match AppendedTimestampForValidation
|
|
workflowID += "-" + nominalTimeSec.Format(time.RFC3339)
|
|
}
|
|
|
|
// Set scheduleToCloseTimeout based on catchup window, which is the latest time that it's
|
|
// acceptable to start this workflow. For manual starts (trigger immediately or backfill),
|
|
// catch up window doesn't apply, so just use 60s.
|
|
options := defaultLocalActivityOptions
|
|
if start.Manual {
|
|
options.ScheduleToCloseTimeout = 60 * time.Second
|
|
} else {
|
|
deadline := start.ActualTime.Add(s.getCatchupWindow())
|
|
options.ScheduleToCloseTimeout = deadline.Sub(s.now())
|
|
if options.ScheduleToCloseTimeout < options.StartToCloseTimeout {
|
|
options.ScheduleToCloseTimeout = options.StartToCloseTimeout
|
|
} else if options.ScheduleToCloseTimeout > 1*time.Hour {
|
|
options.ScheduleToCloseTimeout = 1 * time.Hour
|
|
}
|
|
}
|
|
ctx := workflow.WithLocalActivityOptions(s.ctx, options)
|
|
|
|
req := &schedspb.StartWorkflowRequest{
|
|
Request: &workflowservice.StartWorkflowExecutionRequest{
|
|
WorkflowId: workflowID,
|
|
WorkflowType: newWorkflow.WorkflowType,
|
|
TaskQueue: newWorkflow.TaskQueue,
|
|
Input: newWorkflow.Input,
|
|
WorkflowExecutionTimeout: newWorkflow.WorkflowExecutionTimeout,
|
|
WorkflowRunTimeout: newWorkflow.WorkflowRunTimeout,
|
|
WorkflowTaskTimeout: newWorkflow.WorkflowTaskTimeout,
|
|
Identity: s.identity(),
|
|
RequestId: s.newUUIDString(),
|
|
WorkflowIdReusePolicy: enumspb.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE,
|
|
RetryPolicy: newWorkflow.RetryPolicy,
|
|
Memo: newWorkflow.Memo,
|
|
SearchAttributes: s.addSearchAttributes(newWorkflow.SearchAttributes, nominalTimeSec),
|
|
Header: newWorkflow.Header,
|
|
LastCompletionResult: s.State.LastCompletionResult,
|
|
ContinuedFailure: s.State.ContinuedFailure,
|
|
},
|
|
}
|
|
for {
|
|
var res schedspb.StartWorkflowResponse
|
|
err := workflow.ExecuteLocalActivity(ctx, s.a.StartWorkflow, req).Get(s.ctx, &res)
|
|
|
|
var appErr *temporal.ApplicationError
|
|
var details rateLimitedDetails
|
|
if errors.As(err, &appErr) && appErr.Type() == rateLimitedErrorType && appErr.Details(&details) == nil {
|
|
s.metrics.Counter(metrics.ScheduleRateLimited.GetMetricName()).Inc(1)
|
|
workflow.Sleep(s.ctx, details.Delay)
|
|
req.CompletedRateLimitSleep = true // only use rate limiter once
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &schedpb.ScheduleActionResult{
|
|
ScheduleTime: start.ActualTime,
|
|
ActualTime: res.RealStartTime,
|
|
StartWorkflowResult: &commonpb.WorkflowExecution{
|
|
WorkflowId: workflowID,
|
|
RunId: res.RunId,
|
|
},
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
func (s *scheduler) identity() string {
|
|
return fmt.Sprintf("temporal-scheduler-%s-%s", s.State.Namespace, s.State.ScheduleId)
|
|
}
|
|
|
|
func (s *scheduler) addSearchAttributes(
|
|
attributes *commonpb.SearchAttributes,
|
|
nominal time.Time,
|
|
) *commonpb.SearchAttributes {
|
|
fields := util.CloneMapNonNil(attributes.GetIndexedFields())
|
|
if p, err := payload.Encode(nominal); err == nil {
|
|
fields[searchattribute.TemporalScheduledStartTime] = p
|
|
}
|
|
if p, err := payload.Encode(s.State.ScheduleId); err == nil {
|
|
fields[searchattribute.TemporalScheduledById] = p
|
|
}
|
|
return &commonpb.SearchAttributes{
|
|
IndexedFields: fields,
|
|
}
|
|
}
|
|
|
|
func (s *scheduler) refreshWorkflows(executions []*commonpb.WorkflowExecution) {
|
|
ctx := workflow.WithLocalActivityOptions(s.ctx, defaultLocalActivityOptions)
|
|
futures := make([]workflow.Future, len(executions))
|
|
for i, ex := range executions {
|
|
req := &schedspb.WatchWorkflowRequest{
|
|
// Note: do not send runid here so that we always get the latest one
|
|
Execution: &commonpb.WorkflowExecution{WorkflowId: ex.WorkflowId},
|
|
FirstExecutionRunId: ex.RunId,
|
|
LongPoll: false,
|
|
}
|
|
futures[i] = workflow.ExecuteLocalActivity(ctx, s.a.WatchWorkflow, req)
|
|
}
|
|
for i, ex := range executions {
|
|
s.processWatcherResult(ex.WorkflowId, futures[i])
|
|
}
|
|
}
|
|
|
|
func (s *scheduler) startLongPollWatcher(ex *commonpb.WorkflowExecution) {
|
|
if s.watchingFuture != nil {
|
|
s.logger.Error("startLongPollWatcher called with watcher already running")
|
|
return
|
|
}
|
|
|
|
ctx := workflow.WithActivityOptions(s.ctx, workflow.ActivityOptions{
|
|
StartToCloseTimeout: 365 * 24 * time.Hour,
|
|
RetryPolicy: &temporal.RetryPolicy{
|
|
InitialInterval: 1 * time.Second,
|
|
MaximumInterval: 30 * time.Second,
|
|
},
|
|
HeartbeatTimeout: 65 * time.Second,
|
|
})
|
|
req := &schedspb.WatchWorkflowRequest{
|
|
// Note: do not send runid here so that we always get the latest one
|
|
Execution: &commonpb.WorkflowExecution{WorkflowId: ex.WorkflowId},
|
|
FirstExecutionRunId: ex.RunId,
|
|
LongPoll: true,
|
|
}
|
|
s.watchingFuture = workflow.ExecuteActivity(ctx, s.a.WatchWorkflow, req)
|
|
s.watchingWorkflowId = ex.WorkflowId
|
|
}
|
|
|
|
func (s *scheduler) cancelWorkflow(ex *commonpb.WorkflowExecution) {
|
|
ctx := workflow.WithLocalActivityOptions(s.ctx, defaultLocalActivityOptions)
|
|
areq := &schedspb.CancelWorkflowRequest{
|
|
RequestId: s.newUUIDString(),
|
|
Identity: s.identity(),
|
|
Execution: ex,
|
|
Reason: "cancelled by schedule overlap policy",
|
|
}
|
|
err := workflow.ExecuteLocalActivity(ctx, s.a.CancelWorkflow, areq).Get(s.ctx, nil)
|
|
if err != nil {
|
|
s.logger.Error("cancel workflow failed", "workflow", ex.WorkflowId, "error", err)
|
|
s.metrics.Counter(metrics.ScheduleCancelWorkflowErrors.GetMetricName()).Inc(1)
|
|
}
|
|
// Note: the local activity has completed (or failed) here but the workflow might take time
|
|
// to close since a cancel is only a request.
|
|
// If this failed, that's okay, we'll try it again the next time we try to take an action.
|
|
}
|
|
|
|
func (s *scheduler) terminateWorkflow(ex *commonpb.WorkflowExecution) {
|
|
ctx := workflow.WithLocalActivityOptions(s.ctx, defaultLocalActivityOptions)
|
|
areq := &schedspb.TerminateWorkflowRequest{
|
|
RequestId: s.newUUIDString(),
|
|
Identity: s.identity(),
|
|
Execution: ex,
|
|
Reason: "terminated by schedule overlap policy",
|
|
}
|
|
err := workflow.ExecuteLocalActivity(ctx, s.a.TerminateWorkflow, areq).Get(s.ctx, nil)
|
|
if err != nil {
|
|
s.logger.Error("terminate workflow failed", "workflow", ex.WorkflowId, "error", err)
|
|
s.metrics.Counter(metrics.ScheduleTerminateWorkflowErrors.GetMetricName()).Inc(1)
|
|
}
|
|
// Note: the local activity has completed (or failed) here but we'll still wait until we
|
|
// observe the workflow close (with a watcher) to start the next one.
|
|
// If this failed, that's okay, we'll try it again the next time we try to take an action.
|
|
}
|
|
|
|
func (s *scheduler) newUUIDString() string {
|
|
if len(s.uuidBatch) == 0 {
|
|
panicIfErr(workflow.SideEffect(s.ctx, func(ctx workflow.Context) interface{} {
|
|
out := make([]string, 10)
|
|
for i := range out {
|
|
out[i] = uuid.NewString()
|
|
}
|
|
return out
|
|
}).Get(&s.uuidBatch))
|
|
}
|
|
next := s.uuidBatch[0]
|
|
s.uuidBatch = s.uuidBatch[1:]
|
|
return next
|
|
}
|
|
|
|
func panicIfErr(err error) {
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
func GetListInfoFromStartArgs(args *schedspb.StartScheduleArgs, now time.Time) *schedpb.ScheduleListInfo {
|
|
// note that this does not take into account InitialPatch
|
|
fakeScheduler := &scheduler{
|
|
StartScheduleArgs: *args,
|
|
tweakables: currentTweakablePolicies,
|
|
}
|
|
fakeScheduler.ensureFields()
|
|
fakeScheduler.compileSpec()
|
|
fakeScheduler.State.LastProcessedTime = timestamp.TimePtr(now)
|
|
return fakeScheduler.getListInfo()
|
|
}
|