Skip to content

Commit 47299ea

Browse files
committed
Fix schedule update removing cron/spec when not provided (issue #927)
- Describe current schedule before building update and merge CLI with existing spec - Preserve CronExpressions/Calendars when --cron and --calendar not passed - Preserve Intervals when --interval not passed - Preserve Jitter, TimeZoneName, StartAt, EndAt when corresponding flags not set (fully incremental update using FlagSet.Changed) - Add toScheduleSpecForUpdate(spec, existing); create path still uses toScheduleSpec - Add tests: TestSchedule_Update_PreservesCronWhenNotProvided, TestSchedule_Update_PreservesJitterWhenNotProvided
1 parent 4239a26 commit 47299ea

2 files changed

Lines changed: 178 additions & 22 deletions

File tree

internal/temporalcli/commands.schedule.go

Lines changed: 78 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -221,31 +221,82 @@ func toIntervalSpec(str string) (client.ScheduleIntervalSpec, error) {
221221
}
222222

223223
func (c *ScheduleConfigurationOptions) toScheduleSpec(spec *client.ScheduleSpec) error {
224-
spec.CronExpressions = c.Cron
225-
// Skip not supported
226-
spec.Jitter = c.Jitter.Duration()
227-
spec.TimeZoneName = c.TimeZone
228-
spec.StartAt = c.StartTime.Time()
229-
spec.EndAt = c.EndTime.Time()
224+
return c.toScheduleSpecForUpdate(spec, nil)
225+
}
230226

231-
var err error
232-
for _, calPbStr := range c.Calendar {
233-
var calPb schedpb.CalendarSpec
234-
if err = protojson.Unmarshal([]byte(calPbStr), &calPb); err != nil {
235-
return fmt.Errorf("failed to parse json calendar spec: %w", err)
227+
// toScheduleSpecForUpdate applies CLI options to spec, preserving existing spec
228+
// fields when the user did not pass the corresponding flags (fully incremental update).
229+
// If existing is nil, behaves like toScheduleSpec (no preservation).
230+
func (c *ScheduleConfigurationOptions) toScheduleSpecForUpdate(spec *client.ScheduleSpec, existing *client.ScheduleSpec) error {
231+
// Jitter, TimeZoneName, StartAt, EndAt: preserve from existing when flag not set
232+
if existing != nil && c.FlagSet != nil {
233+
if !c.FlagSet.Changed("jitter") {
234+
spec.Jitter = existing.Jitter
235+
} else {
236+
spec.Jitter = c.Jitter.Duration()
236237
}
237-
cron, err := toCronString(&calPb)
238-
if err != nil {
239-
return err
238+
if !c.FlagSet.Changed("time-zone") {
239+
spec.TimeZoneName = existing.TimeZoneName
240+
} else {
241+
spec.TimeZoneName = c.TimeZone
242+
}
243+
if !c.FlagSet.Changed("start-time") {
244+
spec.StartAt = existing.StartAt
245+
} else {
246+
spec.StartAt = c.StartTime.Time()
247+
}
248+
if !c.FlagSet.Changed("end-time") {
249+
spec.EndAt = existing.EndAt
250+
} else {
251+
spec.EndAt = c.EndTime.Time()
252+
}
253+
} else {
254+
spec.Jitter = c.Jitter.Duration()
255+
spec.TimeZoneName = c.TimeZone
256+
spec.StartAt = c.StartTime.Time()
257+
spec.EndAt = c.EndTime.Time()
258+
}
259+
260+
// Cron/calendar: preserve from existing when not provided by CLI
261+
if len(c.Cron) == 0 && len(c.Calendar) == 0 {
262+
if existing != nil {
263+
spec.CronExpressions = append([]string(nil), existing.CronExpressions...)
264+
spec.Calendars = append([]client.ScheduleCalendarSpec(nil), existing.Calendars...)
265+
} else {
266+
spec.CronExpressions = nil
267+
spec.Calendars = nil
268+
}
269+
} else {
270+
spec.CronExpressions = append([]string(nil), c.Cron...)
271+
var err error
272+
for _, calPbStr := range c.Calendar {
273+
var calPb schedpb.CalendarSpec
274+
if err = protojson.Unmarshal([]byte(calPbStr), &calPb); err != nil {
275+
return fmt.Errorf("failed to parse json calendar spec: %w", err)
276+
}
277+
cron, err := toCronString(&calPb)
278+
if err != nil {
279+
return err
280+
}
281+
spec.CronExpressions = append(spec.CronExpressions, cron)
240282
}
241-
spec.CronExpressions = append(spec.CronExpressions, cron)
242283
}
243-
for _, intStr := range c.Interval {
244-
int, err := toIntervalSpec(intStr)
245-
if err != nil {
246-
return err
284+
285+
// Intervals: preserve from existing when not provided by CLI
286+
if len(c.Interval) == 0 {
287+
if existing != nil {
288+
spec.Intervals = append([]client.ScheduleIntervalSpec(nil), existing.Intervals...)
289+
} else {
290+
spec.Intervals = nil
291+
}
292+
} else {
293+
for _, intStr := range c.Interval {
294+
intv, err := toIntervalSpec(intStr)
295+
if err != nil {
296+
return err
297+
}
298+
spec.Intervals = append(spec.Intervals, intv)
247299
}
248-
spec.Intervals = append(spec.Intervals, int)
249300
}
250301

251302
return nil
@@ -540,13 +591,18 @@ func (c *TemporalScheduleUpdateCommand) run(cctx *CommandContext, args []string)
540591
newSchedule.State.RemainingActions = c.RemainingActions
541592
}
542593

543-
if err = c.toScheduleSpec(newSchedule.Spec); err != nil {
594+
sch := cl.ScheduleClient().GetHandle(cctx, c.ScheduleId)
595+
description, err := sch.Describe(cctx)
596+
if err != nil {
597+
return err
598+
}
599+
600+
if err = c.toScheduleSpecForUpdate(newSchedule.Spec, description.Schedule.Spec); err != nil {
544601
return err
545602
} else if newSchedule.Action, err = toScheduleAction(&c.SharedWorkflowStartOptions, &c.PayloadInputOptions); err != nil {
546603
return err
547604
}
548605

549-
sch := cl.ScheduleClient().GetHandle(cctx, c.ScheduleId)
550606
return sch.Update(cctx, client.ScheduleUpdateOptions{
551607
DoUpdate: func(u client.ScheduleUpdateInput) (*client.ScheduleUpdate, error) {
552608
// replace whole schedule

internal/temporalcli/commands.schedule_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"io"
1010
"math/rand"
1111
"regexp"
12+
"strings"
1213
"time"
1314

1415
"github.com/stretchr/testify/assert"
@@ -510,6 +511,105 @@ func (s *SharedServerSuite) TestSchedule_Update() {
510511
}, 10*time.Second, 100*time.Millisecond)
511512
}
512513

514+
func (s *SharedServerSuite) TestSchedule_Update_PreservesCronWhenNotProvided() {
515+
schedId, schedWfId, res := s.createSchedule("--cron", "0 9 * * *")
516+
s.NoError(res.Err)
517+
518+
// Update with only non-spec flags (no --cron). Existing cron should be preserved.
519+
res = s.Execute(
520+
"schedule", "update",
521+
"--address", s.Address(),
522+
"-s", schedId,
523+
"--task-queue", "SomeOtherTq",
524+
"--type", "SomeOtherWf",
525+
"--workflow-id", schedWfId,
526+
"--overlap-policy", "AllowAll",
527+
)
528+
s.NoError(res.Err)
529+
530+
s.Eventually(func() bool {
531+
res = s.Execute(
532+
"schedule", "describe",
533+
"--address", s.Address(),
534+
"-s", schedId,
535+
"-o", "json",
536+
)
537+
s.NoError(res.Err)
538+
var j struct {
539+
Schedule struct {
540+
Spec struct {
541+
CronExpressions []string `json:"cronExpressions"`
542+
} `json:"spec"`
543+
Action struct {
544+
StartWorkflow struct {
545+
WorkflowType struct {
546+
Name string `json:"name"`
547+
} `json:"workflowType"`
548+
TaskQueue struct {
549+
Name string `json:"name"`
550+
} `json:"taskQueue"`
551+
} `json:"startWorkflow"`
552+
} `json:"action"`
553+
} `json:"schedule"`
554+
Info struct {
555+
FutureActionTimes []string `json:"futureActionTimes"`
556+
} `json:"info"`
557+
}
558+
s.NoError(json.Unmarshal(res.Stdout.Bytes(), &j))
559+
// Cron was preserved: either cronExpressions contains our cron, or futureActionTimes
560+
// are at 09:00 UTC (server may return structuredCalendar instead of cronExpressions).
561+
cronPreserved := (len(j.Schedule.Spec.CronExpressions) == 1 && j.Schedule.Spec.CronExpressions[0] == "0 9 * * *") ||
562+
(len(j.Info.FutureActionTimes) > 0 && (strings.Contains(j.Info.FutureActionTimes[0], "T09:00") || strings.Contains(j.Info.FutureActionTimes[0], " 09:00")))
563+
return cronPreserved &&
564+
j.Schedule.Action.StartWorkflow.WorkflowType.Name == "SomeOtherWf" &&
565+
j.Schedule.Action.StartWorkflow.TaskQueue.Name == "SomeOtherTq"
566+
}, 10*time.Second, 100*time.Millisecond)
567+
}
568+
569+
func (s *SharedServerSuite) TestSchedule_Update_PreservesJitterWhenNotProvided() {
570+
schedId, schedWfId, res := s.createSchedule("--interval", "10d", "--jitter", "1m")
571+
s.NoError(res.Err)
572+
573+
// Update with only non-spec flags (no --jitter). Existing jitter should be preserved.
574+
res = s.Execute(
575+
"schedule", "update",
576+
"--address", s.Address(),
577+
"-s", schedId,
578+
"--task-queue", "SomeOtherTq",
579+
"--type", "SomeOtherWf",
580+
"--workflow-id", schedWfId,
581+
)
582+
s.NoError(res.Err)
583+
584+
s.Eventually(func() bool {
585+
res = s.Execute(
586+
"schedule", "describe",
587+
"--address", s.Address(),
588+
"-s", schedId,
589+
"-o", "json",
590+
)
591+
s.NoError(res.Err)
592+
var j struct {
593+
Schedule struct {
594+
Spec struct {
595+
Jitter string `json:"jitter"`
596+
} `json:"spec"`
597+
Action struct {
598+
StartWorkflow struct {
599+
WorkflowType struct {
600+
Name string `json:"name"`
601+
} `json:"workflowType"`
602+
} `json:"startWorkflow"`
603+
} `json:"action"`
604+
} `json:"schedule"`
605+
}
606+
s.NoError(json.Unmarshal(res.Stdout.Bytes(), &j))
607+
// Jitter 1m is stored as 60s by the server
608+
return (j.Schedule.Spec.Jitter == "60s" || j.Schedule.Spec.Jitter == "1m") &&
609+
j.Schedule.Action.StartWorkflow.WorkflowType.Name == "SomeOtherWf"
610+
}, 10*time.Second, 100*time.Millisecond)
611+
}
612+
513613
func (s *SharedServerSuite) TestSchedule_Memo_Update() {
514614
schedId, schedWfId, res := s.createSchedule("--memo", "bar=1")
515615
s.NoError(res.Err)

0 commit comments

Comments
 (0)