diff --git a/internal/printer/printer.go b/internal/printer/printer.go index cb7995543..b2c8721ec 100644 --- a/internal/printer/printer.go +++ b/internal/printer/printer.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os" + "os/signal" "reflect" "slices" "strconv" @@ -55,6 +56,28 @@ func (p *Printer) Println(s ...string) { p.Print(append(append([]string{}, s...), "\n")...) } +// PrintlnStrictErr prints a line in text output and returns the first write error. +// It returns broken pipe errors to its caller. +// It is ignored during JSON output. +func (p *Printer) PrintlnStrictErr(s ...string) error { + if p.JSON { + return nil + } + sigpipe := make(chan os.Signal, 1) + signal.Notify(sigpipe, syscall.SIGPIPE) + defer signal.Stop(sigpipe) + for _, v := range append(append([]string{}, s...), "\n") { + n, err := p.Output.Write([]byte(v)) + if err != nil { + return err + } + if n != len(v) { + return io.ErrShortWrite + } + } + return nil +} + // Ignored during JSON output func (p *Printer) Printlnf(s string, v ...any) { p.Println(fmt.Sprintf(s, v...)) @@ -70,6 +93,11 @@ func (p *Printer) Printlnf(s string, v ...any) { // [Printer.EndList] must be called at the end. If this is called twice it will // panic. This and the end call are not safe for concurrent use. func (p *Printer) StartList() { + _ = p.StartListErr() +} + +// StartListErr starts list output and returns any opening write error. +func (p *Printer) StartListErr() error { if p.listMode { panic("already in list mode") } @@ -77,13 +105,22 @@ func (p *Printer) StartList() { // Write initial bracket when non-jsonl if p.JSON && p.JSONIndent != "" { // Don't need newline, we count on initial object to do that - p.Output.Write([]byte("[")) + if err := p.writeSafeErr([]byte("[")); err != nil { + p.listMode, p.listModeFirstJSON = false, false + return err + } } + return nil } // Must be called after [Printer.StartList] or will panic. See Godoc on that // function for more details. func (p *Printer) EndList() { + _ = p.EndListErr() +} + +// EndListErr ends list output and returns any final write error. +func (p *Printer) EndListErr() error { if !p.listMode { panic("not in list mode") } @@ -92,8 +129,9 @@ func (p *Printer) EndList() { if p.JSON && p.JSONIndent != "" { // We prepend a newline because non-jsonl list mode doesn't do so after each // line to help with commas - p.Output.Write([]byte("\n]\n")) + return p.writeSafeErr([]byte("\n]\n")) } + return nil } type StructuredOptions struct { @@ -129,29 +167,52 @@ type TableOptions struct { // For JSON, if v is a proto message, protojson encoding is used func (p *Printer) PrintStructured(v any, options StructuredOptions) error { + err, textWriteErr := p.printStructured(v, options, false) + if textWriteErr { + p.handleWriteErr(err) + return nil + } + return err +} + +// PrintStructuredErr prints structured output and returns serialization and +// write errors in both text and JSON modes. +func (p *Printer) PrintStructuredErr(v any, options StructuredOptions) error { + originalOutput := p.Output + p.Output = shortWriteCheckingWriter{Writer: originalOutput} + defer func() { p.Output = originalOutput }() + err, _ := p.printStructured(v, options, true) + return err +} + +func (p *Printer) printStructured( + v any, + options StructuredOptions, + returnTextSerializationErrors bool, +) (err error, textWriteErr bool) { // JSON if p.JSON { - return p.printJSON(v, options) + return p.printJSON(v, options), false } // Get data cols := options.toPredefinedCols() - cols, rows, err := p.tableData(cols, v) + cols, rows, err := p.tableData(cols, v, returnTextSerializationErrors) if err != nil { - return err + return err, false } cols = adjustColsToOptions(cols, options) // Text table if options.Table != nil { p.calculateUnsetColWidths(cols, rows) - p.printTable(options.Table, cols, rows) - return nil + err = p.printTable(options.Table, cols, rows) + return err, err != nil } // Text "card" - p.printCards(cols, rows) - return nil + err = p.printCards(cols, rows) + return err, err != nil } type PrintStructuredIter interface { @@ -178,17 +239,17 @@ func (p *Printer) PrintStructuredTableIter( cols = adjustColsToOptions(cols, options) // We're intentionally not calculating field lengths and only accepting them // since this is streaming - p.printHeader(cols) + p.handleWriteErr(p.printHeader(cols)) for { v, err := iter.Next() if v == nil || err != nil { return err } - row, err := p.tableRowData(cols, v) + row, err := p.tableRowData(cols, v, false) if err != nil { return err } - p.printRow(cols, row) + p.handleWriteErr(p.printRow(cols, row)) } } @@ -206,26 +267,60 @@ func isBrokenPipeError(err error) bool { } func (p *Printer) write(b []byte) { - if _, err := p.Output.Write(b); err != nil { - // Exit gracefully on broken pipe (terminal disconnected) - if isBrokenPipeError(err) { - os.Exit(0) - } - panic(err) + p.handleWriteErr(p.writeErr(b)) +} + +func (p *Printer) handleWriteErr(err error) { + err = preserveBrokenPipeBehavior(err) + if err == nil { + return + } + panic(err) +} + +func (p *Printer) writeErr(b []byte) error { + _, err := p.Output.Write(b) + return preserveBrokenPipeBehavior(err) +} + +func (p *Printer) writeSafeErr(b []byte) error { + n, err := p.Output.Write(b) + if err == nil && n != len(b) { + return io.ErrShortWrite } + return preserveBrokenPipeBehavior(err) +} + +func preserveBrokenPipeBehavior(err error) error { + if isBrokenPipeError(err) { + os.Exit(0) + } + return err } func (p *Printer) writeStr(s string) { p.write([]byte(s)) } +func (p *Printer) writeStrErr(s string) error { + return p.writeErr([]byte(s)) +} + +type shortWriteCheckingWriter struct { + io.Writer +} + +func (w shortWriteCheckingWriter) Write(p []byte) (int, error) { + n, err := w.Writer.Write(p) + if err == nil && n != len(p) { + return n, io.ErrShortWrite + } + return n, err +} + func (p *Printer) writef(s string, v ...any) { if _, err := fmt.Fprintf(p.Output, s, v...); err != nil { - // Exit gracefully on broken pipe (terminal disconnected) - if isBrokenPipeError(err) { - os.Exit(0) - } - panic(err) + p.handleWriteErr(err) } } @@ -241,7 +336,7 @@ func (p *Printer) printJSON(v any, options StructuredOptions) error { } else { prepend = ",\n" } - if _, err := p.Output.Write([]byte(prepend)); err != nil { + if err := p.writeErr([]byte(prepend)); err != nil { return err } } @@ -253,13 +348,13 @@ func (p *Printer) printJSON(v any, options StructuredOptions) error { } if b, err := p.jsonVal(v, p.JSONIndent, shorthandPayloads); err != nil { return err - } else if _, err := p.Output.Write(b); err != nil { + } else if err := p.writeErr(b); err != nil { return err } // Do not print a newline if in non-jsonl list mode if !nonJSONLListMode { - if _, err := p.Output.Write([]byte("\n")); err != nil { + if err := p.writeErr([]byte("\n")); err != nil { return err } } @@ -349,44 +444,57 @@ func adjustColsToOptions(cols []*col, options StructuredOptions) []*col { return adjusted } -func (p *Printer) printTable(options *TableOptions, cols []*col, rows []map[string]colVal) { +func (p *Printer) printTable(options *TableOptions, cols []*col, rows []map[string]colVal) error { if !options.NoHeader { - p.printHeader(cols) + if err := p.printHeader(cols); err != nil { + return err + } } - p.printRows(cols, rows) + return p.printRows(cols, rows) } -func (p *Printer) printHeader(cols []*col) { +func (p *Printer) printHeader(cols []*col) error { colorer := p.TableHeaderColorer if colorer == nil { colorer = color.MagentaString } for _, col := range cols { for i := 0; i < col.indentAmount; i++ { - p.writeStr(NonJSONIndent) + if err := p.writeStrErr(NonJSONIndent); err != nil { + return err + } + } + if err := p.writeStrErr(tablewriter.Pad(colorer("%v", col.name), " ", col.width)); err != nil { + return err } - p.writeStr(tablewriter.Pad(colorer("%v", col.name), " ", col.width)) } - p.writeStr("\n") + return p.writeStrErr("\n") } -func (p *Printer) printRows(cols []*col, rows []map[string]colVal) { +func (p *Printer) printRows(cols []*col, rows []map[string]colVal) error { for _, row := range rows { - p.printRow(cols, row) + if err := p.printRow(cols, row); err != nil { + return err + } } + return nil } -func (p *Printer) printRow(cols []*col, row map[string]colVal) { +func (p *Printer) printRow(cols []*col, row map[string]colVal) error { for _, col := range cols { for i := 0; i < col.indentAmount; i++ { - p.writeStr(NonJSONIndent) + if err := p.writeStrErr(NonJSONIndent); err != nil { + return err + } + } + if err := p.printCol(col, row[col.name].text); err != nil { + return err } - p.printCol(col, row[col.name].text) } - p.writeStr("\n") + return p.writeStrErr("\n") } -func (p *Printer) printCol(col *col, data string) { +func (p *Printer) printCol(col *col, data string) error { switch col.align { case AlignCenter: data = tablewriter.Pad(data, " ", col.width) @@ -395,20 +503,25 @@ func (p *Printer) printCol(col *col, data string) { default: data = tablewriter.PadRight(data, " ", col.width) } - p.writeStr(data) + return p.writeStrErr(data) } -func (p *Printer) printCards(cols []*col, rows []map[string]colVal) { +func (p *Printer) printCards(cols []*col, rows []map[string]colVal) error { for i, row := range rows { // Extra newline between cards if i > 0 { - p.writeStr("\n") + if err := p.writeStrErr("\n"); err != nil { + return err + } + } + if err := p.printCard(cols, row); err != nil { + return err } - p.printCard(cols, row) } + return nil } -func (p *Printer) printCard(cols []*col, row map[string]colVal) { +func (p *Printer) printCard(cols []*col, row map[string]colVal) error { nameValueRows := make([]map[string]colVal, 0, len(cols)) indentAmount := 1 // Since this option applies to everything in a structured print, there should be @@ -432,30 +545,33 @@ func (p *Printer) printCard(cols []*col, row map[string]colVal) { {name: "Value", width: 1, indentAmount: indentAmount}, } p.calculateUnsetColWidths(nameValueCols, nameValueRows) - p.printRows(nameValueCols, nameValueRows) + return p.printRows(nameValueCols, nameValueRows) } var jsonMarshalerType = reflect.TypeOf((*json.Marshaler)(nil)).Elem() -func (p *Printer) textVal(v any) string { +func (p *Printer) textVal(v any, returnSerializationErrors bool) (string, error) { if ref := reflect.Indirect(reflect.ValueOf(v)); ref.IsValid() { if ref.Type() == reflect.TypeOf(time.Time{}) { if ref.IsZero() { - return "" + return "", nil } if p.FormatTime == nil { - return ref.Interface().(time.Time).Format(time.RFC3339) + return ref.Interface().(time.Time).Format(time.RFC3339), nil } - return p.FormatTime(ref.Interface().(time.Time)) + return p.FormatTime(ref.Interface().(time.Time)), nil } else if (ref.Kind() == reflect.Struct && ref.CanInterface()) || ref.Type().Implements(jsonMarshalerType) { b, err := p.jsonVal(v, "", true) if err != nil { - return fmt.Sprintf("", err) + if !returnSerializationErrors { + return fmt.Sprintf("", err), nil + } + return "", err } - return string(b) + return string(b), nil } else if ref.Kind() == reflect.Slice && ref.Type().Elem().Kind() == reflect.Uint8 { b, _ := ref.Interface().([]byte) - return "bytes(" + base64.StdEncoding.EncodeToString(b) + ")" + return "bytes(" + base64.StdEncoding.EncodeToString(b) + ")", nil } else if ref.Kind() == reflect.Slice { // We don't want to reimplement all of fmt.Sprintf, but expanding one level of // slice helps format lists more consistently. @@ -465,16 +581,24 @@ func (p *Printer) textVal(v any) string { if i > 0 { sb.WriteString(", ") } - sb.WriteString(p.textVal(ref.Index(i).Interface())) + text, err := p.textVal(ref.Index(i).Interface(), returnSerializationErrors) + if err != nil { + return "", err + } + sb.WriteString(text) } sb.WriteString("]") - return sb.String() + return sb.String(), nil } } - return fmt.Sprintf("%v", v) + return fmt.Sprintf("%v", v), nil } -func (p *Printer) tableData(predefinedCols []*col, v any) (cols []*col, rows []map[string]colVal, err error) { +func (p *Printer) tableData( + predefinedCols []*col, + v any, + returnTextSerializationErrors bool, +) (cols []*col, rows []map[string]colVal, err error) { singleItemType := reflect.TypeOf(v) if singleItemType.Kind() == reflect.Slice { singleItemType = singleItemType.Elem() @@ -505,7 +629,9 @@ func (p *Printer) tableData(predefinedCols []*col, v any) (cols []*col, rows []m row := make(map[string]colVal, len(cols)) for _, col := range cols { colVal := colVal{val: colValGetter(col, itemVal)} - colVal.text = p.textVal(colVal.val) + if colVal.text, err = p.textVal(colVal.val, returnTextSerializationErrors); err != nil { + return nil, nil, err + } row[col.name] = colVal } rows[i] = row @@ -513,7 +639,11 @@ func (p *Printer) tableData(predefinedCols []*col, v any) (cols []*col, rows []m return } -func (p *Printer) tableRowData(cols []*col, v any) (map[string]colVal, error) { +func (p *Printer) tableRowData( + cols []*col, + v any, + returnTextSerializationErrors bool, +) (map[string]colVal, error) { colValGetter, err := colValGetterForType(reflect.TypeOf(v)) if err != nil { return nil, err @@ -522,7 +652,10 @@ func (p *Printer) tableRowData(cols []*col, v any) (map[string]colVal, error) { itemVal := reflect.ValueOf(v) for _, col := range cols { colVal := colVal{val: colValGetter(col, itemVal)} - colVal.text = p.textVal(colVal.val) + colVal.text, err = p.textVal(colVal.val, returnTextSerializationErrors) + if err != nil { + return nil, err + } row[col.name] = colVal } return row, nil diff --git a/internal/printer/printer_test.go b/internal/printer/printer_test.go index 054332043..56bdad209 100644 --- a/internal/printer/printer_test.go +++ b/internal/printer/printer_test.go @@ -2,10 +2,14 @@ package printer_test import ( "bytes" + "errors" + "io" "os" "os/exec" + "reflect" "runtime" "strings" + "syscall" "testing" "unicode" @@ -13,6 +17,20 @@ import ( "github.com/temporalio/cli/internal/printer" ) +type writerFunc func([]byte) (int, error) + +func (f writerFunc) Write(p []byte) (int, error) { + return f(p) +} + +type failingJSONMarshaler struct { + err error +} + +func (m failingJSONMarshaler) MarshalJSON() ([]byte, error) { + return nil, m.err +} + // TODO(cretz): Test: // * Text printer specific fields // * Text printer specific and non-specific fields and all sorts of table options @@ -97,6 +115,309 @@ func TestPrinter_JSON(t *testing.T) { require.Equal(t, "{\"foo\":\"bar\"}\n", buf.String()) } +func TestPrinter_PrintlnStrictErrReturnsAcknowledgementWriteFailures(t *testing.T) { + const helperEnv = "TEMPORAL_CLI_PRINTER_STRICT_ACKNOWLEDGEMENT_EPIPE" + if os.Getenv(helperEnv) != "" { + p := printer.Printer{Output: writerFunc(func([]byte) (int, error) { + return 0, syscall.EPIPE + })} + if err := p.PrintlnStrictErr("not printed"); err != syscall.EPIPE { + os.Exit(10) + } + _, _ = os.Stderr.WriteString("strict acknowledgement returned EPIPE\n") + return + } + + t.Run("returns the original EPIPE without exiting", func(t *testing.T) { + cmd := exec.Command(os.Args[0], "-test.run=^TestPrinter_PrintlnStrictErrReturnsAcknowledgementWriteFailures$") + cmd.Env = append(os.Environ(), helperEnv+"=1") + output, err := cmd.CombinedOutput() + require.NoError(t, err) + require.Contains(t, string(output), "strict acknowledgement returned EPIPE") + }) + + t.Run("returns short write", func(t *testing.T) { + p := printer.Printer{Output: writerFunc(func(p []byte) (int, error) { + return len(p) - 1, nil + })} + require.ErrorIs(t, p.PrintlnStrictErr("not printed"), io.ErrShortWrite) + }) + + t.Run("is silent in JSON", func(t *testing.T) { + var output bytes.Buffer + p := printer.Printer{Output: &output, JSON: true} + require.NoError(t, p.PrintlnStrictErr("not printed")) + require.Empty(t, output.String()) + }) +} + +func TestPrinter_PrintlnStrictErrReturnsEPIPEFromRealStdoutPipe(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows pipes do not generate SIGPIPE") + } + const ( + helperEnv = "TEMPORAL_CLI_PRINTER_STRICT_REAL_STDOUT_PIPE" + helperExitCode = 23 + helperMarker = "strict real stdout pipe returned EPIPE\n" + ) + if os.Getenv(helperEnv) != "" { + p := printer.Printer{Output: os.Stdout} + if err := p.PrintlnStrictErr("not printed"); !errors.Is(err, syscall.EPIPE) { + _, _ = os.Stderr.WriteString("strict real stdout pipe did not return EPIPE\n") + os.Exit(24) + } + _, _ = os.Stderr.WriteString(helperMarker) + os.Exit(helperExitCode) + } + + pipeReader, pipeWriter, err := os.Pipe() + require.NoError(t, err) + require.NoError(t, pipeReader.Close()) + t.Cleanup(func() { _ = pipeWriter.Close() }) + + cmd := exec.Command(os.Args[0], "-test.run=^TestPrinter_PrintlnStrictErrReturnsEPIPEFromRealStdoutPipe$") + cmd.Env = append(os.Environ(), helperEnv+"=1") + cmd.Stdout = pipeWriter + var stderr bytes.Buffer + cmd.Stderr = &stderr + err = cmd.Run() + require.NoError(t, pipeWriter.Close()) + + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) + require.Equal(t, helperExitCode, exitErr.ExitCode(), "stderr: %s", stderr.String()) + require.Contains(t, stderr.String(), helperMarker) +} + +func TestPrinter_StartListErrReturnsWriteFailure(t *testing.T) { + wantErr := errors.New("write failed") + p := printer.Printer{ + Output: writerFunc(func([]byte) (int, error) { + return 0, wantErr + }), + JSON: true, + JSONIndent: " ", + } + + require.ErrorIs(t, p.StartListErr(), wantErr) +} + +func TestPrinter_StartListErrCanStartNewListAfterWriteFailure(t *testing.T) { + wantErr := errors.New("write failed") + p := printer.Printer{ + Output: writerFunc(func([]byte) (int, error) { + return 0, wantErr + }), + JSON: true, + JSONIndent: " ", + } + require.ErrorIs(t, p.StartListErr(), wantErr) + + var output bytes.Buffer + p.Output = &output + require.NoError(t, p.StartListErr()) + require.NoError(t, p.PrintStructured(map[string]string{"key": "value"}, printer.StructuredOptions{})) + require.NoError(t, p.EndListErr()) + require.Equal(t, `[ +{ + "key": "value" +} +] +`, output.String()) +} + +func TestPrinter_EndListErrReturnsWriteFailure(t *testing.T) { + wantErr := errors.New("write failed") + writes := 0 + p := printer.Printer{ + Output: writerFunc(func(p []byte) (int, error) { + writes++ + if writes == 1 { + return len(p), nil + } + return 0, wantErr + }), + JSON: true, + JSONIndent: " ", + } + require.NoError(t, p.StartListErr()) + + require.ErrorIs(t, p.EndListErr(), wantErr) +} + +func TestPrinter_EndListErrResetsListStateAfterWriteFailure(t *testing.T) { + wantErr := errors.New("write failed") + p := printer.Printer{ + Output: &bytes.Buffer{}, + JSON: true, + JSONIndent: " ", + } + require.NoError(t, p.StartListErr()) + p.Output = writerFunc(func([]byte) (int, error) { + return 0, wantErr + }) + require.ErrorIs(t, p.EndListErr(), wantErr) + p.Output = &bytes.Buffer{} + + require.NoError(t, p.StartListErr()) +} + +func TestPrinter_VoidListBoundariesRetainWriteFailureBehavior(t *testing.T) { + wantErr := errors.New("write failed") + failingOutput := writerFunc(func([]byte) (int, error) { + return 0, wantErr + }) + + t.Run("start", func(t *testing.T) { + p := printer.Printer{Output: failingOutput, JSON: true, JSONIndent: " "} + require.NotPanics(t, p.StartList) + }) + + t.Run("end", func(t *testing.T) { + p := printer.Printer{Output: &bytes.Buffer{}, JSON: true, JSONIndent: " "} + p.StartList() + p.Output = failingOutput + require.NotPanics(t, p.EndList) + }) + + t.Run("println short write", func(t *testing.T) { + p := printer.Printer{Output: writerFunc(func(p []byte) (int, error) { + return len(p) - 1, nil + })} + require.NotPanics(t, func() { p.Println("partially printed") }) + }) +} + +func TestPrinter_PrintStructuredTextRetainsWriteFailureBehavior(t *testing.T) { + wantErr := errors.New("write failed") + p := printer.Printer{Output: writerFunc(func([]byte) (int, error) { + return 0, wantErr + })} + + require.PanicsWithValue(t, wantErr, func() { + _ = p.PrintStructured(struct{ Value string }{Value: "not printed"}, printer.StructuredOptions{}) + }) +} + +func TestPrinter_PrintStructuredTextRetainsSerializationFailureBehavior(t *testing.T) { + wantErr := errors.New("serialization failed") + var buf bytes.Buffer + p := printer.Printer{Output: &buf} + + require.NoError(t, p.PrintStructured(struct { + Value failingJSONMarshaler + }{Value: failingJSONMarshaler{err: wantErr}}, printer.StructuredOptions{})) + require.Contains(t, buf.String(), "") +} + +func TestPrinter_PrintStructuredTextRetainsDataErrorBehavior(t *testing.T) { + p := printer.Printer{Output: &bytes.Buffer{}} + + err := p.PrintStructured(map[string]string{"key": "value"}, printer.StructuredOptions{}) + + require.ErrorContains(t, err, "cannot derive fields from map") +} + +func TestPrinter_PrintStructuredTableIterRetainsWriteFailureBehavior(t *testing.T) { + wantErr := errors.New("write failed") + p := printer.Printer{Output: writerFunc(func([]byte) (int, error) { + return 0, wantErr + })} + + require.PanicsWithValue(t, wantErr, func() { + _ = p.PrintStructuredTableIter( + reflect.TypeOf(struct{ Value string }{}), + nil, + printer.StructuredOptions{Table: &printer.TableOptions{}}, + ) + }) +} + +func TestPrinter_PrintStructuredErrTextReturnsWriteFailure(t *testing.T) { + wantErr := errors.New("write failed") + p := printer.Printer{Output: writerFunc(func([]byte) (int, error) { + return 0, wantErr + })} + + err := p.PrintStructuredErr( + struct{ Value string }{Value: "not printed"}, + printer.StructuredOptions{}, + ) + + require.ErrorIs(t, err, wantErr) +} + +func TestPrinter_PrintStructuredErrTextReturnsSerializationFailure(t *testing.T) { + wantErr := errors.New("serialization failed") + p := printer.Printer{Output: &bytes.Buffer{}} + + err := p.PrintStructuredErr(struct { + Value failingJSONMarshaler + }{Value: failingJSONMarshaler{err: wantErr}}, printer.StructuredOptions{}) + + require.ErrorIs(t, err, wantErr) +} + +func TestPrinter_PrintStructuredJSONRetainsShortWriteBehavior(t *testing.T) { + p := printer.Printer{ + Output: writerFunc(func(p []byte) (int, error) { + return len(p) - 1, nil + }), + JSON: true, + } + + require.NoError(t, p.PrintStructured(map[string]string{"key": "value"}, printer.StructuredOptions{})) +} + +func TestPrinter_PrintStructuredErrReturnsShortWrite(t *testing.T) { + p := printer.Printer{ + Output: writerFunc(func(p []byte) (int, error) { + return len(p) - 1, nil + }), + JSON: true, + } + + require.ErrorIs(t, p.PrintStructuredErr(map[string]string{"key": "value"}, printer.StructuredOptions{}), io.ErrShortWrite) +} + +func TestPrinter_ErrorReturningMethodsExitSuccessfullyOnBrokenPipe(t *testing.T) { + const helperEnv = "TEMPORAL_CLI_PRINTER_BROKEN_PIPE_METHOD" + if method := os.Getenv(helperEnv); method != "" { + brokenPipeOutput := writerFunc(func([]byte) (int, error) { + return 0, syscall.EPIPE + }) + var err error + switch method { + case "start-list": + p := printer.Printer{Output: brokenPipeOutput, JSON: true, JSONIndent: " "} + err = p.StartListErr() + case "print-structured": + p := printer.Printer{Output: brokenPipeOutput, JSON: true} + err = p.PrintStructuredErr(map[string]string{"key": "value"}, printer.StructuredOptions{}) + case "end-list": + p := printer.Printer{Output: &bytes.Buffer{}, JSON: true, JSONIndent: " "} + require.NoError(t, p.StartListErr()) + p.Output = brokenPipeOutput + err = p.EndListErr() + default: + os.Exit(12) + } + if err != nil { + os.Exit(10) + } + os.Exit(11) + } + + for _, method := range []string{"start-list", "print-structured", "end-list"} { + t.Run(method, func(t *testing.T) { + cmd := exec.Command(os.Args[0], "-test.run=^TestPrinter_ErrorReturningMethodsExitSuccessfullyOnBrokenPipe$") + cmd.Env = append(os.Environ(), helperEnv+"="+method) + require.NoError(t, cmd.Run()) + }) + } +} + func TestPrinter_JSONList(t *testing.T) { var buf bytes.Buffer diff --git a/internal/temporalcli/commands.gen.go b/internal/temporalcli/commands.gen.go index 7806d6149..8fc32d8c4 100644 --- a/internal/temporalcli/commands.gen.go +++ b/internal/temporalcli/commands.gen.go @@ -247,9 +247,9 @@ func (v *SharedWorkflowStartOptions) BuildFlags(f *pflag.FlagSet) { f.StringArrayVar(&v.Memo, "memo", nil, "Memo using 'KEY=\"VALUE\"' pairs. Use JSON values.") f.StringVar(&v.StaticSummary, "static-summary", "", "Static Workflow summary for human consumption in UIs. Uses Temporal Markdown formatting, should be a single line. EXPERIMENTAL.") f.StringVar(&v.StaticDetails, "static-details", "", "Static Workflow details for human consumption in UIs. Uses Temporal Markdown formatting, may be multiple lines. EXPERIMENTAL.") - f.IntVar(&v.PriorityKey, "priority-key", 0, "Priority key (1-5, lower numbers = higher priority). Tasks in a queue should be processed in close-to-priority-order. Default is 3 when not specified.") - f.StringVar(&v.FairnessKey, "fairness-key", "", "Fairness key (max 64 bytes) for proportional task dispatch. Tasks with same key share capacity based on their weight.") - f.Float32Var(&v.FairnessWeight, "fairness-weight", 0, "Weight [0.001-1000] for this fairness key. Keys are dispatched proportionally to their weights.") + f.IntVar(&v.PriorityKey, "priority-key", 0, "Priority key passed to the server. Lower values have higher priority. Zero uses the server-configured default.") + f.StringVar(&v.FairnessKey, "fairness-key", "", "Fairness key for proportional task dispatch. Tasks with same key share capacity based on their weight.") + f.Float32Var(&v.FairnessWeight, "fairness-weight", 0, "Weight for this fairness key. Keys are dispatched proportionally to their weights.") } type WorkflowStartOptions struct { @@ -2496,6 +2496,7 @@ func NewTemporalScheduleCommand(cctx *CommandContext, parent *TemporalCommand) * s.Command.AddCommand(&NewTemporalScheduleDescribeCommand(cctx, &s).Command) s.Command.AddCommand(&NewTemporalScheduleListCommand(cctx, &s).Command) s.Command.AddCommand(&NewTemporalScheduleListMatchingTimesCommand(cctx, &s).Command) + s.Command.AddCommand(&NewTemporalSchedulePatchCommand(cctx, &s).Command) s.Command.AddCommand(&NewTemporalScheduleToggleCommand(cctx, &s).Command) s.Command.AddCommand(&NewTemporalScheduleTriggerCommand(cctx, &s).Command) s.Command.AddCommand(&NewTemporalScheduleUpdateCommand(cctx, &s).Command) @@ -2696,6 +2697,107 @@ func NewTemporalScheduleListMatchingTimesCommand(cctx *CommandContext, parent *T return &s } +type TemporalSchedulePatchCommand struct { + Parent *TemporalScheduleCommand + Command cobra.Command + ScheduleIdOptions + OverlapPolicyOptions + CatchupWindow cliext.FlagDuration + UnsetCatchupWindow bool + PauseOnFailure bool + Notes string + UnsetNotes bool + Paused bool + RemainingActions int + Calendar []string + Cron []string + Interval []string + SpecClearAll bool + StartTime cliext.FlagTimestamp + UnsetStartTime bool + EndTime cliext.FlagTimestamp + UnsetEndTime bool + Jitter cliext.FlagDuration + UnsetJitter bool + TimeZone string + UnsetTimeZone bool + WorkflowId string + Type string + TaskQueue string + ExecutionTimeout cliext.FlagDuration + UnsetExecutionTimeout bool + RunTimeout cliext.FlagDuration + UnsetRunTimeout bool + TaskTimeout cliext.FlagDuration + UnsetTaskTimeout bool + StaticSummary string + UnsetStaticSummary bool + StaticDetails string + UnsetStaticDetails bool +} + +func NewTemporalSchedulePatchCommand(cctx *CommandContext, parent *TemporalScheduleCommand) *TemporalSchedulePatchCommand { + var s TemporalSchedulePatchCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "patch [flags]" + s.Command.Short = "Change selected Schedule fields" + if hasHighlighting { + s.Command.Long = "Change selected fields on an existing Schedule while preserving\nunspecified fields.\n\nFor example:\n\n\x1b[1mtemporal schedule patch \\\n --schedule-id \"YourScheduleId\" \\\n --notes \"Runs every hour\" \\\n --interval \"1h\"\x1b[0m\n\nA successful command confirms that the patch was submitted, not that it\nhas been applied on every backend.\n\nWhen none of \x1b[1m--calendar\x1b[0m, \x1b[1m--cron\x1b[0m, or \x1b[1m--interval\x1b[0m is supplied,\nexisting calendar, cron, and interval specifications are preserved.\nSupplying any of them replaces all existing calendar, cron, and interval\nspecifications. \x1b[1m--spec-clear-all\x1b[0m removes all existing calendar, cron,\nand interval specifications.\nExclusion calendars, start time, end time, jitter, and time zone are\npreserved unless separately changed." + } else { + s.Command.Long = "Change selected fields on an existing Schedule while preserving\nunspecified fields.\n\nFor example:\n\n```\ntemporal schedule patch \\\n --schedule-id \"YourScheduleId\" \\\n --notes \"Runs every hour\" \\\n --interval \"1h\"\n```\n\nA successful command confirms that the patch was submitted, not that it\nhas been applied on every backend.\n\nWhen none of `--calendar`, `--cron`, or `--interval` is supplied,\nexisting calendar, cron, and interval specifications are preserved.\nSupplying any of them replaces all existing calendar, cron, and interval\nspecifications. `--spec-clear-all` removes all existing calendar, cron,\nand interval specifications.\nExclusion calendars, start time, end time, jitter, and time zone are\npreserved unless separately changed." + } + s.Command.Args = cobra.NoArgs + s.CatchupWindow = 0 + s.Command.Flags().Var(&s.CatchupWindow, "catchup-window", "Maximum catch-up time for when the Service is unavailable.") + s.Command.Flags().BoolVar(&s.UnsetCatchupWindow, "unset-catchup-window", false, "Restore the default catch-up window behavior.") + s.Command.Flags().BoolVar(&s.PauseOnFailure, "pause-on-failure", false, "Pause the Schedule after Workflow failures.") + s.Command.Flags().StringVar(&s.Notes, "notes", "", "Set the Schedule notes field.") + s.Command.Flags().BoolVar(&s.UnsetNotes, "unset-notes", false, "Clear the Schedule notes field.") + s.Command.Flags().BoolVar(&s.Paused, "paused", false, "Set whether the Schedule is paused.") + s.Command.Flags().IntVar(&s.RemainingActions, "remaining-actions", 0, "Total allowed actions. Zero means unlimited.") + s.Command.Flags().StringArrayVar(&s.Calendar, "calendar", nil, "Calendar JSON specification. May be passed multiple times. Supplying any calendar, cron, or interval value replaces all existing calendar, cron, and interval specifications.") + s.Command.Flags().StringArrayVar(&s.Cron, "cron", nil, "Cron expression. May be passed multiple times. Supplying any calendar, cron, or interval value replaces all existing calendar, cron, and interval specifications.") + s.Command.Flags().StringArrayVar(&s.Interval, "interval", nil, "Interval specification. May be passed multiple times. Supplying any calendar, cron, or interval value replaces all existing calendar, cron, and interval specifications.") + s.Command.Flags().BoolVar(&s.SpecClearAll, "spec-clear-all", false, "Clear all calendar, cron, and interval specifications from the Schedule Spec. Exclusion calendars and other Schedule Spec fields are preserved. Requires the resulting Schedule to be paused.") + s.Command.Flags().Var(&s.StartTime, "start-time", "Set the Schedule start time.") + s.Command.Flags().BoolVar(&s.UnsetStartTime, "unset-start-time", false, "Clear the Schedule start time.") + s.Command.Flags().Var(&s.EndTime, "end-time", "Set the Schedule end time.") + s.Command.Flags().BoolVar(&s.UnsetEndTime, "unset-end-time", false, "Clear the Schedule end time.") + s.Jitter = 0 + s.Command.Flags().Var(&s.Jitter, "jitter", "Set the Schedule jitter.") + s.Command.Flags().BoolVar(&s.UnsetJitter, "unset-jitter", false, "Clear the Schedule jitter.") + s.Command.Flags().StringVar(&s.TimeZone, "time-zone", "", "Set the Schedule time zone.") + s.Command.Flags().BoolVar(&s.UnsetTimeZone, "unset-time-zone", false, "Restore default Schedule time zone interpretation.") + s.Command.Flags().StringVarP(&s.WorkflowId, "workflow-id", "w", "", "Set the Workflow ID. An empty value is invalid.") + s.Command.Flags().StringVar(&s.Type, "type", "", "Set the Workflow Type name. An empty value is invalid. Aliased as \"--name\".") + s.Command.Flags().StringVarP(&s.TaskQueue, "task-queue", "t", "", "Set the Workflow Task queue. An empty value is invalid.") + s.ExecutionTimeout = 0 + s.Command.Flags().Var(&s.ExecutionTimeout, "execution-timeout", "Set the Workflow Execution timeout.") + s.Command.Flags().BoolVar(&s.UnsetExecutionTimeout, "unset-execution-timeout", false, "Remove the explicit Workflow Execution timeout.") + s.RunTimeout = 0 + s.Command.Flags().Var(&s.RunTimeout, "run-timeout", "Set the Workflow Run timeout.") + s.Command.Flags().BoolVar(&s.UnsetRunTimeout, "unset-run-timeout", false, "Restore the inherited Workflow Run timeout.") + s.TaskTimeout = cliext.MustParseFlagDuration("10s") + s.Command.Flags().Var(&s.TaskTimeout, "task-timeout", "Set the Workflow Task timeout.") + s.Command.Flags().BoolVar(&s.UnsetTaskTimeout, "unset-task-timeout", false, "Restore the 10-second default Workflow Task timeout.") + s.Command.Flags().StringVar(&s.StaticSummary, "static-summary", "", "Set the static Workflow summary for human consumption in UIs. Uses Temporal Markdown formatting, should be a single line. EXPERIMENTAL.") + s.Command.Flags().BoolVar(&s.UnsetStaticSummary, "unset-static-summary", false, "Remove the static Workflow summary.") + s.Command.Flags().StringVar(&s.StaticDetails, "static-details", "", "Set the static Workflow details for human consumption in UIs. Uses Temporal Markdown formatting, may be multiple lines. EXPERIMENTAL.") + s.Command.Flags().BoolVar(&s.UnsetStaticDetails, "unset-static-details", false, "Remove the static Workflow details.") + s.ScheduleIdOptions.BuildFlags(s.Command.Flags()) + s.OverlapPolicyOptions.BuildFlags(s.Command.Flags()) + s.Command.Flags().SetNormalizeFunc(aliasNormalizer(map[string]string{ + "name": "type", + })) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + type TemporalScheduleToggleCommand struct { Parent *TemporalScheduleCommand Command cobra.Command @@ -2775,9 +2877,9 @@ func NewTemporalScheduleUpdateCommand(cctx *CommandContext, parent *TemporalSche s.Command.Use = "update [flags]" s.Command.Short = "Update Schedule details" if hasHighlighting { - s.Command.Long = "Update an existing Schedule with new configuration details, including time\nspecifications, action, and policies:\n\n\x1b[1mtemporal schedule update \\\n --schedule-id \"YourScheduleId\" \\\n --workflow-id YourBaseWorkflowIdName \\\n --task-queue YourTaskQueue \\\n --type YourWorkflowType\x1b[0m\n\nThis command performs a full replacement of the Schedule\nconfiguration. Any options not provided will be reset to their default\nvalues. You must re-specify all options, not just the ones you want to\nchange. To view the current configuration of a Schedule, use\n\x1b[1mtemporal schedule describe\x1b[0m before updating.\n\nSchedule memo and search attributes cannot be updated with this\ncommand. They are set only during Schedule creation and are not affected\nby updates." + s.Command.Long = "Update an existing Schedule with new configuration details, including time\nspecifications, action, and policies:\n\n\x1b[1mtemporal schedule update \\\n --schedule-id \"YourScheduleId\" \\\n --workflow-id YourBaseWorkflowIdName \\\n --task-queue YourTaskQueue \\\n --type YourWorkflowType\x1b[0m\n\nThis command performs a full replacement of the Schedule\nconfiguration. Any options not provided will be reset to their default\nvalues. You must re-specify all options, not just the ones you want to\nchange. To view the current configuration of a Schedule, use\n\x1b[1mtemporal schedule describe\x1b[0m before updating.\n\nSchedule memo and search attributes cannot be updated with this\ncommand. They are set only during Schedule creation and are not affected\nby updates.\n\nFor field-preserving changes to individual fields, use\n\x1b[1mtemporal schedule patch\x1b[0m." } else { - s.Command.Long = "Update an existing Schedule with new configuration details, including time\nspecifications, action, and policies:\n\n```\ntemporal schedule update \\\n --schedule-id \"YourScheduleId\" \\\n --workflow-id YourBaseWorkflowIdName \\\n --task-queue YourTaskQueue \\\n --type YourWorkflowType\n```\n\nThis command performs a full replacement of the Schedule\nconfiguration. Any options not provided will be reset to their default\nvalues. You must re-specify all options, not just the ones you want to\nchange. To view the current configuration of a Schedule, use\n`temporal schedule describe` before updating.\n\nSchedule memo and search attributes cannot be updated with this\ncommand. They are set only during Schedule creation and are not affected\nby updates." + s.Command.Long = "Update an existing Schedule with new configuration details, including time\nspecifications, action, and policies:\n\n```\ntemporal schedule update \\\n --schedule-id \"YourScheduleId\" \\\n --workflow-id YourBaseWorkflowIdName \\\n --task-queue YourTaskQueue \\\n --type YourWorkflowType\n```\n\nThis command performs a full replacement of the Schedule\nconfiguration. Any options not provided will be reset to their default\nvalues. You must re-specify all options, not just the ones you want to\nchange. To view the current configuration of a Schedule, use\n`temporal schedule describe` before updating.\n\nSchedule memo and search attributes cannot be updated with this\ncommand. They are set only during Schedule creation and are not affected\nby updates.\n\nFor field-preserving changes to individual fields, use\n`temporal schedule patch`." } s.Command.Args = cobra.NoArgs s.ScheduleConfigurationOptions.BuildFlags(s.Command.Flags()) diff --git a/internal/temporalcli/commands.schedule.go b/internal/temporalcli/commands.schedule.go index 3a18271bd..61854dd0f 100644 --- a/internal/temporalcli/commands.schedule.go +++ b/internal/temporalcli/commands.schedule.go @@ -3,21 +3,28 @@ package temporalcli import ( "errors" "fmt" + "math" "regexp" "strconv" "strings" "time" + "github.com/google/uuid" "github.com/temporalio/cli/cliext" "github.com/temporalio/cli/internal/printer" "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/timestamppb" commonpb "go.temporal.io/api/common/v1" enumspb "go.temporal.io/api/enums/v1" schedpb "go.temporal.io/api/schedule/v1" + sdkpb "go.temporal.io/api/sdk/v1" + "go.temporal.io/api/serviceerror" + taskqueuepb "go.temporal.io/api/taskqueue/v1" "go.temporal.io/api/workflowservice/v1" "go.temporal.io/sdk/client" + "google.golang.org/protobuf/proto" ) type printableSchedule struct { @@ -253,6 +260,13 @@ func (c *ScheduleConfigurationOptions) toScheduleSpec(spec *client.ScheduleSpec) } func toScheduleAction(sw *SharedWorkflowStartOptions, i *PayloadInputOptions) (client.ScheduleAction, error) { + if len(sw.Headers) > 0 { + return nil, fmt.Errorf("headers are not supported for schedule actions") + } + if sw.PriorityKey < math.MinInt32 || sw.PriorityKey > math.MaxInt32 { + return nil, fmt.Errorf("priority key must be between %d and %d", math.MinInt32, math.MaxInt32) + } + opts, err := buildStartOptions(sw, &WorkflowStartOptions{}) if err != nil { return nil, err @@ -273,6 +287,7 @@ func toScheduleAction(sw *SharedWorkflowStartOptions, i *PayloadInputOptions) (c Memo: opts.Memo, StaticSummary: opts.StaticSummary, StaticDetails: opts.StaticDetails, + Priority: opts.Priority, } if action.Args, err = i.buildRawInput(); err != nil { return nil, err @@ -347,10 +362,9 @@ func (c *TemporalScheduleDescribeCommand) run(cctx *CommandContext, args []strin } // TODO: remove this after https://github.com/temporalio/api-go/pull/154 noShorthand := false - cctx.Printer.PrintStructured(res, printer.StructuredOptions{ + return cctx.Printer.PrintStructuredErr(res, printer.StructuredOptions{ OverrideJSONPayloadShorthand: &noShorthand, }) - return nil } sch := cl.ScheduleClient().GetHandle(cctx, c.ScheduleId) @@ -360,7 +374,11 @@ func (c *TemporalScheduleDescribeCommand) run(cctx *CommandContext, args []strin } printable := describeResultToPrintable(c.ScheduleId, res) - return cctx.Printer.PrintStructured(printable, printer.StructuredOptions{}) + return cctx.Printer.PrintStructuredErr(printable, printer.StructuredOptions{}) +} + +func finishScheduleList(p *printer.Printer, primaryErr error) error { + return errors.Join(primaryErr, p.EndListErr()) } func (c *TemporalScheduleListCommand) run(cctx *CommandContext, args []string) error { @@ -373,8 +391,9 @@ func (c *TemporalScheduleListCommand) run(cctx *CommandContext, args []string) e if cctx.JSONOutput { // Use raw gRPC for stability // This is a listing command subject to json vs jsonl rules - cctx.Printer.StartList() - defer cctx.Printer.EndList() + if err := cctx.Printer.StartListErr(); err != nil { + return err + } var token []byte for { @@ -384,21 +403,23 @@ func (c *TemporalScheduleListCommand) run(cctx *CommandContext, args []string) e Query: c.Query, }) if err != nil { - return err + return finishScheduleList(cctx.Printer, err) } // TODO: remove this after https://github.com/temporalio/api-go/pull/154 noShorthand := false for _, entry := range res.Schedules { - cctx.Printer.PrintStructured(entry, printer.StructuredOptions{ + if err := cctx.Printer.PrintStructuredErr(entry, printer.StructuredOptions{ OverrideJSONPayloadShorthand: &noShorthand, - }) + }); err != nil { + return finishScheduleList(cctx.Printer, err) + } } if token = res.NextPageToken; len(token) == 0 { break } } - return nil + return finishScheduleList(cctx.Printer, nil) } res, err := cl.ScheduleClient().List(cctx, client.ScheduleListOptions{ @@ -409,8 +430,9 @@ func (c *TemporalScheduleListCommand) run(cctx *CommandContext, args []string) e } // This is a listing command subject to json vs jsonl rules - cctx.Printer.StartList() - defer cctx.Printer.EndList() + if err := cctx.Printer.StartListErr(); err != nil { + return err + } printOpts := printer.StructuredOptions{ ExcludeFields: []string{ @@ -453,18 +475,22 @@ func (c *TemporalScheduleListCommand) run(cctx *CommandContext, args []string) e for res.HasNext() { ent, err := res.Next() if err != nil { - return err + return finishScheduleList(cctx.Printer, err) } page = append(page, listEntryToPrintable(ent)) if len(page) == cap(page) { - cctx.Printer.PrintStructured(page, printOpts) + if err := cctx.Printer.PrintStructuredErr(page, printOpts); err != nil { + return finishScheduleList(cctx.Printer, err) + } page = page[:0] printOpts.Table.NoHeader = true } } - cctx.Printer.PrintStructured(page, printOpts) + if err := cctx.Printer.PrintStructuredErr(page, printOpts); err != nil { + return finishScheduleList(cctx.Printer, err) + } - return nil + return finishScheduleList(cctx.Printer, nil) } func (c *TemporalScheduleToggleCommand) run(cctx *CommandContext, args []string) error { @@ -513,6 +539,492 @@ func (c *TemporalScheduleTriggerCommand) run(cctx *CommandContext, args []string return nil } +type schedulePatchIntent struct { + notesSet bool + notesUnset bool + notes string + overlapSet bool + overlap enumspb.ScheduleOverlapPolicy + catchupSet bool + catchup time.Duration + catchupUnset bool + pauseOnFailureSet bool + pauseOnFailure bool + pausedSet bool + paused bool + remainingActionsSet bool + remainingActions int + calendarSet bool + cronSet bool + intervalSet bool + calendar []string + cron []string + interval []*schedpb.IntervalSpec + specClearAll bool + startTimeSet bool + startTimeUnset bool + startTime *timestamppb.Timestamp + endTimeSet bool + endTimeUnset bool + endTime *timestamppb.Timestamp + jitterSet bool + jitterUnset bool + jitter time.Duration + timeZoneSet bool + timeZoneUnset bool + timeZone string + workflowIDSet bool + workflowID string + workflowTypeSet bool + workflowType string + taskQueueSet bool + taskQueue string + executionTimeoutSet bool + executionTimeoutUnset bool + executionTimeout time.Duration + runTimeoutSet bool + runTimeoutUnset bool + runTimeout time.Duration + taskTimeoutSet bool + taskTimeoutUnset bool + taskTimeout time.Duration + staticSummarySet bool + staticSummaryUnset bool + staticSummary *commonpb.Payload + staticDetailsSet bool + staticDetailsUnset bool + staticDetails *commonpb.Payload +} + +func (i schedulePatchIntent) hasAnyPatch() bool { + return i.notesSet || i.notesUnset || i.hasPolicyPatch() || i.pausedSet || + i.remainingActionsSet || i.hasSpecPatch() || i.hasWorkflowActionPatch() +} + +func (i schedulePatchIntent) hasPolicyPatch() bool { + return i.overlapSet || i.catchupSet || i.catchupUnset || i.pauseOnFailureSet +} + +func (i schedulePatchIntent) hasSpecPatch() bool { + return i.hasScheduleSpecSourcePatch() || i.specClearAll || i.startTimeSet || + i.startTimeUnset || i.endTimeSet || i.endTimeUnset || i.jitterSet || + i.jitterUnset || i.timeZoneSet || i.timeZoneUnset +} + +func (i schedulePatchIntent) hasScheduleSpecSourcePatch() bool { + return i.calendarSet || i.cronSet || i.intervalSet +} + +func (i schedulePatchIntent) hasWorkflowActionPatch() bool { + return i.workflowIDSet || i.workflowTypeSet || i.taskQueueSet || + i.executionTimeoutSet || i.executionTimeoutUnset || i.runTimeoutSet || + i.runTimeoutUnset || i.taskTimeoutSet || i.taskTimeoutUnset || + i.staticSummarySet || i.staticSummaryUnset || i.staticDetailsSet || + i.staticDetailsUnset +} + +func (i schedulePatchIntent) validate() error { + if !i.hasAnyPatch() { + return errors.New("at least one patch operation is required") + } + if i.notesSet && i.notesUnset { + return errors.New("--notes and --unset-notes are mutually exclusive") + } + if i.catchupSet && i.catchupUnset { + return errors.New("--catchup-window and --unset-catchup-window are mutually exclusive") + } + if i.catchupSet && i.catchup < 10*time.Second { + return errors.New("catchup window must be at least 10s") + } + if i.remainingActionsSet && i.remainingActions < 0 { + return errors.New("remaining actions must not be negative") + } + if i.startTimeSet && i.startTimeUnset { + return errors.New("--start-time and --unset-start-time are mutually exclusive") + } + if i.startTimeSet { + if err := i.startTime.CheckValid(); err != nil { + return fmt.Errorf("invalid start time: %w", err) + } + } + if i.endTimeSet && i.endTimeUnset { + return errors.New("--end-time and --unset-end-time are mutually exclusive") + } + if i.endTimeSet { + if err := i.endTime.CheckValid(); err != nil { + return fmt.Errorf("invalid end time: %w", err) + } + } + if i.jitterSet && i.jitterUnset { + return errors.New("--jitter and --unset-jitter are mutually exclusive") + } + if i.timeZoneSet && i.timeZoneUnset { + return errors.New("--time-zone and --unset-time-zone are mutually exclusive") + } + if i.timeZoneSet && strings.TrimSpace(i.timeZone) == "" { + return errors.New("--time-zone requires a non-empty value; use --unset-time-zone to clear") + } + if i.workflowIDSet && i.workflowID == "" { + return errors.New("workflow ID must not be empty") + } + if i.workflowTypeSet && i.workflowType == "" { + return errors.New("workflow type must not be empty") + } + if i.taskQueueSet && i.taskQueue == "" { + return errors.New("task queue must not be empty") + } + if i.executionTimeoutSet && i.executionTimeoutUnset { + return errors.New("--execution-timeout and --unset-execution-timeout are mutually exclusive") + } + if i.executionTimeoutSet && i.executionTimeout < 0 { + return errors.New("execution timeout must not be negative") + } + if i.runTimeoutSet && i.runTimeoutUnset { + return errors.New("--run-timeout and --unset-run-timeout are mutually exclusive") + } + if i.runTimeoutSet && i.runTimeout < 0 { + return errors.New("run timeout must not be negative") + } + if i.taskTimeoutSet && i.taskTimeoutUnset { + return errors.New("--task-timeout and --unset-task-timeout are mutually exclusive") + } + if i.taskTimeoutSet && i.taskTimeout < 0 { + return errors.New("task timeout must not be negative") + } + if i.staticSummarySet && i.staticSummaryUnset { + return errors.New("--static-summary and --unset-static-summary are mutually exclusive") + } + if i.staticDetailsSet && i.staticDetailsUnset { + return errors.New("--static-details and --unset-static-details are mutually exclusive") + } + if i.specClearAll && i.hasScheduleSpecSourcePatch() { + return errors.New("--spec-clear-all cannot be combined with --calendar, --cron, or --interval") + } + if i.jitterSet && i.jitter < 0 { + return errors.New("jitter must not be negative") + } + return nil +} + +func (i schedulePatchIntent) validateResult(schedule *schedpb.Schedule) error { + if i.specClearAll && !schedule.GetState().GetPaused() { + return errors.New("--spec-clear-all requires the Schedule to be paused; use --paused=true to pause explicitly") + } + return nil +} + +func (i schedulePatchIntent) apply(schedule *schedpb.Schedule) error { + if i.hasPolicyPatch() { + if schedule.Policies == nil { + schedule.Policies = &schedpb.SchedulePolicies{} + } + } + if i.overlapSet { + schedule.Policies.OverlapPolicy = i.overlap + } + if i.catchupSet { + schedule.Policies.CatchupWindow = durationpb.New(i.catchup) + } + if i.catchupUnset { + schedule.Policies.CatchupWindow = nil + } + if i.pauseOnFailureSet { + schedule.Policies.PauseOnFailure = i.pauseOnFailure + } + if i.pausedSet { + if schedule.State == nil { + schedule.State = &schedpb.ScheduleState{} + } + schedule.State.Paused = i.paused + } + if i.remainingActionsSet { + if schedule.State == nil { + schedule.State = &schedpb.ScheduleState{} + } + schedule.State.RemainingActions = int64(i.remainingActions) + schedule.State.LimitedActions = i.remainingActions > 0 + } + if i.hasSpecPatch() { + if schedule.Spec == nil { + schedule.Spec = &schedpb.ScheduleSpec{} + } + } + if i.hasScheduleSpecSourcePatch() || i.specClearAll { + schedule.Spec.StructuredCalendar = nil + schedule.Spec.Calendar = nil + schedule.Spec.CronString = nil + schedule.Spec.Interval = nil + } + if i.hasScheduleSpecSourcePatch() { + schedule.Spec.CronString = append(append([]string(nil), i.calendar...), i.cron...) + schedule.Spec.Interval = append([]*schedpb.IntervalSpec(nil), i.interval...) + } + if i.startTimeSet { + schedule.Spec.StartTime = i.startTime + } + if i.startTimeUnset { + schedule.Spec.StartTime = nil + } + if i.endTimeSet { + schedule.Spec.EndTime = i.endTime + } + if i.endTimeUnset { + schedule.Spec.EndTime = nil + } + if i.jitterSet { + schedule.Spec.Jitter = durationpb.New(i.jitter) + } + if i.jitterUnset { + schedule.Spec.Jitter = nil + } + if i.timeZoneSet { + schedule.Spec.TimezoneName = i.timeZone + schedule.Spec.TimezoneData = nil + } + if i.timeZoneUnset { + schedule.Spec.TimezoneName = "" + schedule.Spec.TimezoneData = nil + } + if i.hasWorkflowActionPatch() { + startWorkflow := schedule.GetAction().GetStartWorkflow() + if startWorkflow == nil { + return errors.New("schedule action does not contain a StartWorkflow action") + } + if i.workflowIDSet { + startWorkflow.WorkflowId = i.workflowID + } + if i.workflowTypeSet { + if startWorkflow.WorkflowType == nil { + startWorkflow.WorkflowType = &commonpb.WorkflowType{} + } + startWorkflow.WorkflowType.Name = i.workflowType + } + if i.taskQueueSet { + if startWorkflow.TaskQueue == nil { + startWorkflow.TaskQueue = &taskqueuepb.TaskQueue{} + } + startWorkflow.TaskQueue.Name = i.taskQueue + } + if i.executionTimeoutSet { + startWorkflow.WorkflowExecutionTimeout = durationpb.New(i.executionTimeout) + } + if i.executionTimeoutUnset { + startWorkflow.WorkflowExecutionTimeout = nil + } + if i.runTimeoutSet { + startWorkflow.WorkflowRunTimeout = durationpb.New(i.runTimeout) + } + if i.runTimeoutUnset { + startWorkflow.WorkflowRunTimeout = nil + } + if i.taskTimeoutSet { + startWorkflow.WorkflowTaskTimeout = durationpb.New(i.taskTimeout) + } + if i.taskTimeoutUnset { + startWorkflow.WorkflowTaskTimeout = durationpb.New(10 * time.Second) + } + if i.staticSummarySet || i.staticDetailsSet { + if startWorkflow.UserMetadata == nil { + startWorkflow.UserMetadata = &sdkpb.UserMetadata{} + } + } + if i.staticSummarySet { + startWorkflow.UserMetadata.Summary = i.staticSummary + } + if i.staticDetailsSet { + startWorkflow.UserMetadata.Details = i.staticDetails + } + if i.staticSummaryUnset && startWorkflow.UserMetadata != nil { + startWorkflow.UserMetadata.Summary = nil + } + if i.staticDetailsUnset && startWorkflow.UserMetadata != nil { + startWorkflow.UserMetadata.Details = nil + } + } + if !i.notesSet && !i.notesUnset { + return nil + } + if schedule.State == nil { + schedule.State = &schedpb.ScheduleState{} + } + if i.notesSet { + schedule.State.Notes = i.notes + return nil + } + schedule.State.Notes = "" + return nil +} + +func parseScheduleSpecSources(calendar, cron, intervals []string) ([]string, []*schedpb.IntervalSpec, error) { + calendarCron := make([]string, 0, len(calendar)) + for _, calendarJSON := range calendar { + var calendarSpec schedpb.CalendarSpec + if err := protojson.Unmarshal([]byte(calendarJSON), &calendarSpec); err != nil { + return nil, nil, fmt.Errorf("failed to parse json calendar spec: %w", err) + } + calendarCronString, err := toCronString(&calendarSpec) + if err != nil { + return nil, nil, err + } + calendarCron = append(calendarCron, calendarCronString) + } + intervalSpecs := make([]*schedpb.IntervalSpec, 0, len(intervals)) + for _, intervalString := range intervals { + interval, err := toIntervalSpec(intervalString) + if err != nil { + return nil, nil, err + } + if interval.Every < time.Second { + return nil, nil, errors.New("interval must be at least 1s") + } + if interval.Offset < 0 { + return nil, nil, errors.New("interval phase must not be negative") + } + if interval.Offset >= interval.Every { + return nil, nil, errors.New("interval phase must be less than the interval") + } + intervalSpecs = append(intervalSpecs, &schedpb.IntervalSpec{ + Interval: durationpb.New(interval.Every), + Phase: durationpb.New(interval.Offset), + }) + } + return calendarCron, intervalSpecs, nil +} + +func (c *TemporalSchedulePatchCommand) run(cctx *CommandContext, args []string) error { + const maxAttempts = 3 + + var err error + intent := schedulePatchIntent{ + notesSet: c.Command.Flags().Changed("notes"), + notesUnset: c.UnsetNotes, + notes: c.Notes, + overlapSet: c.Command.Flags().Changed("overlap-policy"), + catchupSet: c.Command.Flags().Changed("catchup-window"), + catchup: c.CatchupWindow.Duration(), + catchupUnset: c.UnsetCatchupWindow, + pauseOnFailureSet: c.Command.Flags().Changed("pause-on-failure"), + pauseOnFailure: c.PauseOnFailure, + pausedSet: c.Command.Flags().Changed("paused"), + paused: c.Paused, + remainingActionsSet: c.Command.Flags().Changed("remaining-actions"), + remainingActions: c.RemainingActions, + calendarSet: c.Command.Flags().Changed("calendar"), + cronSet: c.Command.Flags().Changed("cron"), + intervalSet: c.Command.Flags().Changed("interval"), + cron: c.Cron, + specClearAll: c.SpecClearAll, + startTimeSet: c.Command.Flags().Changed("start-time"), + startTimeUnset: c.UnsetStartTime, + startTime: timestamppb.New(c.StartTime.Time()), + endTimeSet: c.Command.Flags().Changed("end-time"), + endTimeUnset: c.UnsetEndTime, + endTime: timestamppb.New(c.EndTime.Time()), + jitterSet: c.Command.Flags().Changed("jitter"), + jitterUnset: c.UnsetJitter, + jitter: c.Jitter.Duration(), + timeZoneSet: c.Command.Flags().Changed("time-zone"), + timeZoneUnset: c.UnsetTimeZone, + timeZone: c.TimeZone, + workflowIDSet: c.Command.Flags().Changed("workflow-id"), + workflowID: c.WorkflowId, + workflowTypeSet: c.Command.Flags().Changed("type"), + workflowType: c.Type, + taskQueueSet: c.Command.Flags().Changed("task-queue"), + taskQueue: c.TaskQueue, + executionTimeoutSet: c.Command.Flags().Changed("execution-timeout"), + executionTimeoutUnset: c.UnsetExecutionTimeout, + executionTimeout: c.ExecutionTimeout.Duration(), + runTimeoutSet: c.Command.Flags().Changed("run-timeout"), + runTimeoutUnset: c.UnsetRunTimeout, + runTimeout: c.RunTimeout.Duration(), + taskTimeoutSet: c.Command.Flags().Changed("task-timeout"), + taskTimeoutUnset: c.UnsetTaskTimeout, + taskTimeout: c.TaskTimeout.Duration(), + staticSummarySet: c.Command.Flags().Changed("static-summary"), + staticSummaryUnset: c.UnsetStaticSummary, + staticDetailsSet: c.Command.Flags().Changed("static-details"), + staticDetailsUnset: c.UnsetStaticDetails, + } + if intent.staticSummarySet { + intent.staticSummary, err = DataConverterWithRawValue.ToPayload(c.StaticSummary) + if err != nil { + return fmt.Errorf("failed to encode static summary: %w", err) + } + } + if intent.staticDetailsSet { + intent.staticDetails, err = DataConverterWithRawValue.ToPayload(c.StaticDetails) + if err != nil { + return fmt.Errorf("failed to encode static details: %w", err) + } + } + if intent.hasScheduleSpecSourcePatch() { + intent.calendar, intent.interval, err = parseScheduleSpecSources(c.Calendar, c.Cron, c.Interval) + if err != nil { + return err + } + } + if intent.overlapSet { + intent.overlap, err = enumspb.ScheduleOverlapPolicyFromString(c.OverlapPolicy.Value) + if err != nil { + return err + } + } + if err := intent.validate(); err != nil { + return err + } + if c.ScheduleId == "" { + return errors.New("schedule ID is required") + } + + cl, err := dialClient(cctx, &c.Parent.ClientOptions) + if err != nil { + return err + } + defer cl.Close() + + for attempt := 0; attempt < maxAttempts; attempt++ { + describeResponse, err := cl.WorkflowService().DescribeSchedule(cctx, &workflowservice.DescribeScheduleRequest{ + Namespace: c.Parent.Namespace, + ScheduleId: c.ScheduleId, + }) + if err != nil { + return err + } + if describeResponse.GetSchedule() == nil { + return fmt.Errorf("DescribeSchedule response for Schedule %q did not contain a Schedule", c.ScheduleId) + } + + schedule := proto.Clone(describeResponse.Schedule).(*schedpb.Schedule) + if err := intent.apply(schedule); err != nil { + return err + } + if err := intent.validateResult(schedule); err != nil { + return err + } + _, err = cl.WorkflowService().UpdateSchedule(cctx, &workflowservice.UpdateScheduleRequest{ + Namespace: c.Parent.Namespace, + ScheduleId: c.ScheduleId, + Schedule: schedule, + ConflictToken: describeResponse.ConflictToken, + Identity: c.Parent.Identity, + RequestId: uuid.NewString(), + }) + if err == nil { + break + } + conflictErr, ok := err.(*serviceerror.FailedPrecondition) + if !ok || conflictErr.Message != "mismatched conflict token" || attempt == maxAttempts-1 { + return err + } + } + if err := cctx.Printer.PrintlnStrictErr("Schedule patch submitted"); err != nil { + fmt.Fprintln(cctx.Options.Stderr, "Schedule patch may already have been submitted") + return err + } + return nil +} + func (c *TemporalScheduleUpdateCommand) run(cctx *CommandContext, args []string) error { cl, err := dialClient(cctx, &c.Parent.ClientOptions) if err != nil { diff --git a/internal/temporalcli/commands.schedule.internal_test.go b/internal/temporalcli/commands.schedule.internal_test.go new file mode 100644 index 000000000..1b3d77a25 --- /dev/null +++ b/internal/temporalcli/commands.schedule.internal_test.go @@ -0,0 +1,105 @@ +package temporalcli + +import ( + "math" + "strings" + "testing" + + "go.temporal.io/sdk/client" +) + +func TestToScheduleActionAppliesPriorityKey(t *testing.T) { + action, err := toScheduleAction(&SharedWorkflowStartOptions{ + PriorityKey: 42, + }, &PayloadInputOptions{}) + if err != nil { + t.Fatalf("toScheduleAction returned an unexpected error: %v", err) + } + + scheduleAction, ok := action.(*client.ScheduleWorkflowAction) + if !ok { + t.Fatalf("toScheduleAction returned %T, want *client.ScheduleWorkflowAction", action) + } + if scheduleAction.Priority.PriorityKey != 42 { + t.Errorf("PriorityKey = %d, want 42", scheduleAction.Priority.PriorityKey) + } +} + +func TestToScheduleActionAppliesFairnessFieldsWithoutPriorityKey(t *testing.T) { + action, err := toScheduleAction(&SharedWorkflowStartOptions{ + FairnessKey: "tenant-a", + FairnessWeight: 2.5, + }, &PayloadInputOptions{}) + if err != nil { + t.Fatalf("toScheduleAction returned an unexpected error: %v", err) + } + + scheduleAction, ok := action.(*client.ScheduleWorkflowAction) + if !ok { + t.Fatalf("toScheduleAction returned %T, want *client.ScheduleWorkflowAction", action) + } + if scheduleAction.Priority.PriorityKey != 0 { + t.Errorf("PriorityKey = %d, want 0", scheduleAction.Priority.PriorityKey) + } + if scheduleAction.Priority.FairnessKey != "tenant-a" { + t.Errorf("FairnessKey = %q, want %q", scheduleAction.Priority.FairnessKey, "tenant-a") + } + if scheduleAction.Priority.FairnessWeight != 2.5 { + t.Errorf("FairnessWeight = %v, want 2.5", scheduleAction.Priority.FairnessWeight) + } +} + +func TestToScheduleActionForwardsServerPolicyValuesAndValidatesPriorityRepresentation(t *testing.T) { + testCases := []struct { + name string + options SharedWorkflowStartOptions + wantErr bool + }{ + {name: "default priority and fairness", options: SharedWorkflowStartOptions{}}, + {name: "minimum priority", options: SharedWorkflowStartOptions{PriorityKey: math.MinInt32}}, + {name: "negative priority", options: SharedWorkflowStartOptions{PriorityKey: -1}}, + {name: "server configured priority", options: SharedWorkflowStartOptions{PriorityKey: 6}}, + {name: "maximum representable priority", options: SharedWorkflowStartOptions{PriorityKey: math.MaxInt32}}, + {name: "priority below int32 minimum", options: SharedWorkflowStartOptions{PriorityKey: math.MinInt32 - 1}, wantErr: true}, + {name: "priority above int32 maximum", options: SharedWorkflowStartOptions{PriorityKey: math.MaxInt32 + 1}, wantErr: true}, + {name: "empty fairness key and zero weight", options: SharedWorkflowStartOptions{}}, + {name: "fairness key longer than 64 bytes", options: SharedWorkflowStartOptions{FairnessKey: strings.Repeat("a", 65)}}, + {name: "negative fairness weight", options: SharedWorkflowStartOptions{FairnessWeight: -1}}, + {name: "fairness weight below prior minimum", options: SharedWorkflowStartOptions{FairnessWeight: 0.0009}}, + {name: "fairness weight above prior maximum", options: SharedWorkflowStartOptions{FairnessWeight: 1000.1}}, + {name: "NaN fairness weight", options: SharedWorkflowStartOptions{FairnessWeight: float32(math.NaN())}}, + {name: "positive infinite fairness weight", options: SharedWorkflowStartOptions{FairnessWeight: float32(math.Inf(1))}}, + {name: "negative infinite fairness weight", options: SharedWorkflowStartOptions{FairnessWeight: float32(math.Inf(-1))}}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + action, err := toScheduleAction(&testCase.options, &PayloadInputOptions{}) + if (err != nil) != testCase.wantErr { + t.Fatalf("toScheduleAction error = %v, want error = %v", err, testCase.wantErr) + } + if testCase.wantErr { + return + } + scheduleAction, ok := action.(*client.ScheduleWorkflowAction) + if !ok { + t.Fatalf("toScheduleAction returned %T, want *client.ScheduleWorkflowAction", action) + } + if scheduleAction.Priority.PriorityKey != testCase.options.PriorityKey { + t.Errorf("PriorityKey = %d, want %d", scheduleAction.Priority.PriorityKey, testCase.options.PriorityKey) + } + if scheduleAction.Priority.FairnessKey != testCase.options.FairnessKey { + t.Errorf("FairnessKey = %q, want %q", scheduleAction.Priority.FairnessKey, testCase.options.FairnessKey) + } + if math.IsNaN(float64(testCase.options.FairnessWeight)) { + if !math.IsNaN(float64(scheduleAction.Priority.FairnessWeight)) { + t.Errorf("FairnessWeight = %v, want NaN", scheduleAction.Priority.FairnessWeight) + } + return + } + if scheduleAction.Priority.FairnessWeight != testCase.options.FairnessWeight { + t.Errorf("FairnessWeight = %v, want %v", scheduleAction.Priority.FairnessWeight, testCase.options.FairnessWeight) + } + }) + } +} diff --git a/internal/temporalcli/commands.schedule_patch_test.go b/internal/temporalcli/commands.schedule_patch_test.go new file mode 100644 index 000000000..8507f46b9 --- /dev/null +++ b/internal/temporalcli/commands.schedule_patch_test.go @@ -0,0 +1,2902 @@ +package temporalcli_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "strconv" + "strings" + "sync" + "sync/atomic" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/temporalio/cli/internal/temporalcli" + commonpb "go.temporal.io/api/common/v1" + "go.temporal.io/api/enums/v1" + "go.temporal.io/api/schedule/v1" + sdkpb "go.temporal.io/api/sdk/v1" + "go.temporal.io/api/serviceerror" + taskqueuepb "go.temporal.io/api/taskqueue/v1" + workflowpb "go.temporal.io/api/workflow/v1" + "go.temporal.io/api/workflowservice/v1" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +func (s *SharedServerSuite) TestSchedule_PatchHelpRegistersPatchOptions() { + scheduleHelp := s.Execute("schedule", "--help") + s.NoError(scheduleHelp.Err) + s.Contains(strings.Join(strings.Fields(scheduleHelp.Stdout.String()), " "), "patch Change selected Schedule fields") + + res := s.Execute("schedule", "patch", "--help") + s.NoError(res.Err) + normalizedHelp := strings.Join(strings.Fields(res.Stdout.String()), " ") + s.Contains(normalizedHelp, "Change selected fields on an existing Schedule while preserving unspecified fields") + s.Regexp("temporal schedule patch \\\\\n\\s+--schedule-id \\\"YourScheduleId\\\" \\\\\n\\s+--notes \\\"Runs every hour\\\" \\\\\n\\s+--interval \\\"1h\\\"", res.Stdout.String()) + s.Contains(normalizedHelp, "A successful command confirms that the patch was submitted, not that it has been applied on every backend") + s.Contains(normalizedHelp, "Calendar JSON specification. May be passed multiple times.") + s.Contains(normalizedHelp, "Cron expression. May be passed multiple times.") + s.Contains(normalizedHelp, "Interval specification. May be passed multiple times.") + s.Regexp(`--static-summary string Set the static Workflow summary for human consumption in UIs\. Uses Temporal Markdown formatting, should be a single line\. EXPERIMENTAL\.`, normalizedHelp) + s.Regexp(`--static-details string Set the static Workflow details for human consumption in UIs\. Uses Temporal Markdown formatting, may be multiple lines\. EXPERIMENTAL\.`, normalizedHelp) + s.Contains(res.Stdout.String(), "--overlap-policy") + s.Contains(res.Stdout.String(), "--catchup-window") + s.Contains(res.Stdout.String(), "--unset-catchup-window") + s.Contains(res.Stdout.String(), "--pause-on-failure") + s.Contains(res.Stdout.String(), "--paused") + s.Contains(res.Stdout.String(), "--remaining-actions") + s.Contains(res.Stdout.String(), "--notes") + s.Contains(res.Stdout.String(), "--unset-notes") + for _, option := range []string{ + "--calendar", + "--cron", + "--interval", + "--spec-clear-all", + "--start-time", + "--unset-start-time", + "--end-time", + "--unset-end-time", + "--jitter", + "--unset-jitter", + "--time-zone", + "--unset-time-zone", + "--workflow-id", + "--type", + "--task-queue", + "--execution-timeout", + "--unset-execution-timeout", + "--run-timeout", + "--unset-run-timeout", + "--task-timeout", + "--unset-task-timeout", + "--static-summary", + "--unset-static-summary", + "--static-details", + "--unset-static-details", + } { + s.Contains(res.Stdout.String(), option) + } + s.Contains(res.Stdout.String(), "Aliased as \"--name\"") + s.Contains(res.Stdout.String(), "Remove the explicit Workflow") + s.Contains(res.Stdout.String(), "Restore the inherited Workflow Run") + s.Contains(res.Stdout.String(), "Restore the 10-second default") + s.Contains(res.Stdout.String(), "Remove the static Workflow summary") + s.Contains(res.Stdout.String(), "Remove the static Workflow details") + s.Contains(normalizedHelp, "When none of `--calendar`, `--cron`, or `--interval` is supplied, existing calendar, cron, and interval specifications are preserved.") + s.Contains(normalizedHelp, "Supplying any of them replaces all existing calendar, cron, and interval specifications.") + s.Contains(normalizedHelp, "`--spec-clear-all` removes all existing calendar, cron, and interval specifications.") + s.Contains(normalizedHelp, "Exclusion calendars, start time, end time, jitter, and time zone are preserved unless separately changed.") + s.Contains(normalizedHelp, "Clear all calendar, cron, and interval specifications from the Schedule Spec. Exclusion calendars and other Schedule Spec fields are preserved. Requires the resulting Schedule to be paused.") + s.NotContains(strings.ToLower(res.Stdout.String()), "cadence") + s.NotContains(res.Stdout.String(), "--headers") + s.NotContains(res.Stdout.String(), "--memo") + s.NotContains(res.Stdout.String(), "--search-attribute") + s.NotContains(res.Stdout.String(), "--input") + s.NotContains(res.Stdout.String(), "--priority-key") + s.NotContains(res.Stdout.String(), "--fairness-key") + s.NotContains(res.Stdout.String(), "--fairness-weight") + s.NotContains(res.Stdout.String(), "--unset-workflow-id") + s.NotContains(res.Stdout.String(), "--unset-type") + s.NotContains(res.Stdout.String(), "--unset-task-queue") + + res = s.Execute("schedule", "update", "--help") + s.NoError(res.Err) + s.Contains(res.Stdout.String(), "full replacement") + s.Contains(res.Stdout.String(), "field-preserving") + s.Contains(res.Stdout.String(), "temporal schedule patch") +} + +func (s *SharedServerSuite) TestSchedule_PatchUpdatesStoredNotes() { + const ( + initialNotes = "initial notes" + patchedNotes = "patched notes" + ) + scheduleID, _, res := s.createSchedule("--interval", "10d", "--notes", initialNotes) + s.NoError(res.Err) + + res = s.Execute( + "schedule", "patch", + "--address", s.Address(), + "--schedule-id", scheduleID, + "--notes", patchedNotes, + ) + s.NoError(res.Err) + + s.Eventually(func() bool { + res = s.Execute( + "schedule", "describe", + "--address", s.Address(), + "--schedule-id", scheduleID, + "--output", "json", + ) + if res.Err != nil { + return false + } + var description struct { + Schedule struct { + State struct { + Notes string `json:"notes"` + } `json:"state"` + } `json:"schedule"` + } + if err := json.Unmarshal(res.Stdout.Bytes(), &description); err != nil { + return false + } + return description.Schedule.State.Notes == patchedNotes + }, 10*time.Second, 100*time.Millisecond) +} + +func (s *SharedServerSuite) TestSchedule_PatchRejectsInvalidArgumentsBeforeMutation() { + var scheduleRequests atomic.Int32 + s.DevServer.SetServerInterceptor(func( + ctx context.Context, + req any, + info *grpc.UnaryServerInfo, + handler grpc.UnaryHandler, + ) (any, error) { + switch req.(type) { + case *workflowservice.DescribeScheduleRequest, *workflowservice.UpdateScheduleRequest: + scheduleRequests.Add(1) + } + return handler(ctx, req) + }) + + for _, tc := range []struct { + name string + args []string + errorContains string + }{ + { + name: "missing schedule ID", + args: []string{"schedule", "patch", "--notes", "note"}, + errorContains: "required flag(s) \"schedule-id\" not set", + }, + { + name: "headers", + args: []string{"schedule", "patch", "--schedule-id", "schedule-id", "--notes", "note", "--headers", "example=123"}, + errorContains: "unknown flag: --headers", + }, + } { + scheduleRequests.Store(0) + res := s.Execute(tc.args...) + assert.Error(s.T(), res.Err, tc.name) + assert.ErrorContains(s.T(), res.Err, tc.errorContains, tc.name) + assert.Equal(s.T(), int32(0), scheduleRequests.Load(), tc.name) + } +} + +func (s *SharedServerSuite) TestSchedule_PatchForwardsCronTimeZonePrefixesUnchanged() { + describedSchedule := &schedule.Schedule{ + Spec: &schedule.ScheduleSpec{CronString: []string{"0 12 * * *"}}, + State: &schedule.ScheduleState{}, + } + var updateRequests []*workflowservice.UpdateScheduleRequest + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + switch request := req.(type) { + case *workflowservice.DescribeScheduleRequest: + reply.(*workflowservice.DescribeScheduleResponse).Schedule = proto.Clone(describedSchedule).(*schedule.Schedule) + return nil + case *workflowservice.UpdateScheduleRequest: + updateRequests = append(updateRequests, proto.Clone(request).(*workflowservice.UpdateScheduleRequest)) + return nil + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + for _, cron := range []string{"TZ=UTC 0 12 * * *", "CRON_TZ=UTC 0 12 * * *"} { + s.T().Run(cron, func(t *testing.T) { + updateRequests = nil + res := s.Execute("schedule", "patch", "--address", s.Address(), "--schedule-id", "time-zone-prefix", "--cron", cron) + assert.NoError(t, res.Err) + assert.Len(t, updateRequests, 1) + if len(updateRequests) == 1 { + assert.Equal(t, []string{cron}, updateRequests[0].GetSchedule().GetSpec().GetCronString()) + } + }) + } +} + +func (s *SharedServerSuite) TestSchedule_PatchSemanticValidationFailsBeforeDial() { + dialErr := errors.New("unexpected gRPC dial") + + for _, tc := range []struct { + name string + args []string + errorContains string + }{ + { + name: "no operation", + args: []string{"--schedule-id", "schedule-id"}, + errorContains: "at least one patch operation is required", + }, + { + name: "set and unset notes", + args: []string{"--schedule-id", "schedule-id", "--notes", "note", "--unset-notes"}, + errorContains: "--notes and --unset-notes are mutually exclusive", + }, + { + name: "set and unset catchup window", + args: []string{"--schedule-id", "schedule-id", "--catchup-window", "10s", "--unset-catchup-window"}, + errorContains: "--catchup-window and --unset-catchup-window are mutually exclusive", + }, + { + name: "catchup window below minimum", + args: []string{"--schedule-id", "schedule-id", "--catchup-window", "9s"}, + errorContains: "catchup window must be at least 10s", + }, + { + name: "zero catchup window", + args: []string{"--schedule-id", "schedule-id", "--catchup-window", "0s"}, + errorContains: "catchup window must be at least 10s", + }, + { + name: "negative remaining actions", + args: []string{"--schedule-id", "schedule-id", "--remaining-actions", "-1"}, + errorContains: "remaining actions must not be negative", + }, + {name: "malformed calendar", args: []string{"--schedule-id", "schedule-id", "--calendar", "{"}, errorContains: "failed to parse json calendar spec"}, + {name: "malformed interval", args: []string{"--schedule-id", "schedule-id", "--interval", "nonsense"}, errorContains: "invalid interval"}, + {name: "interval below one second", args: []string{"--schedule-id", "schedule-id", "--interval", "500ms"}, errorContains: "interval must be at least 1s"}, + {name: "negative interval phase", args: []string{"--schedule-id", "schedule-id", "--interval", "1h/-1s"}, errorContains: "interval phase must not be negative"}, + {name: "interval phase too large", args: []string{"--schedule-id", "schedule-id", "--interval", "1h/1h"}, errorContains: "interval phase must be less than the interval"}, + {name: "negative jitter", args: []string{"--schedule-id", "schedule-id", "--jitter", "-1s"}, errorContains: "jitter must not be negative"}, + {name: "start timestamp before protobuf range", args: []string{"--schedule-id", "schedule-id", "--start-time", "0000-01-01T00:00:00Z"}, errorContains: "invalid start time"}, + {name: "start timestamp normalized before protobuf range", args: []string{"--schedule-id", "schedule-id", "--start-time", "0001-01-01T00:00:00+14:00"}, errorContains: "invalid start time"}, + {name: "end timestamp normalized after protobuf range", args: []string{"--schedule-id", "schedule-id", "--end-time", "9999-12-31T23:59:59-14:00"}, errorContains: "invalid end time"}, + {name: "empty time zone set", args: []string{"--schedule-id", "schedule-id", "--time-zone="}, errorContains: "use --unset-time-zone"}, + {name: "whitespace time zone set", args: []string{"--schedule-id", "schedule-id", "--time-zone", " \t "}, errorContains: "use --unset-time-zone"}, + {name: "clear and calendar", args: []string{"--schedule-id", "schedule-id", "--spec-clear-all", "--calendar", `{"minute":"5"}`}, errorContains: "cannot be combined"}, + {name: "clear and cron", args: []string{"--schedule-id", "schedule-id", "--spec-clear-all", "--cron", "0 12 * * *"}, errorContains: "cannot be combined"}, + {name: "clear and interval", args: []string{"--schedule-id", "schedule-id", "--spec-clear-all", "--interval", "1h"}, errorContains: "cannot be combined"}, + { + name: "empty schedule ID", + args: []string{"--schedule-id=", "--notes", "note"}, + errorContains: "schedule ID is required", + }, + } { + s.T().Run(tc.name, func(t *testing.T) { + var dialAttempts atomic.Int32 + options := s.CommandHarness.Options + options.Args = append([]string{"schedule", "patch"}, tc.args...) + options.AdditionalClientGRPCDialOptions = append( + options.AdditionalClientGRPCDialOptions, + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { + dialAttempts.Add(1) + return nil, dialErr + }), + ) + var commandErr error + options.Fail = func(err error) { commandErr = err } + + temporalcli.Execute(context.Background(), options) + + assert.ErrorContains(t, commandErr, tc.errorContains) + assert.Equal(t, int32(0), dialAttempts.Load()) + assert.NotErrorIs(t, commandErr, dialErr) + }) + } + +} + +func (s *SharedServerSuite) TestSchedule_PatchMalformedPolicyAndStateFlagsFailBeforeScheduleRPC() { + var scheduleRequests atomic.Int32 + s.DevServer.SetServerInterceptor(func( + ctx context.Context, + req any, + info *grpc.UnaryServerInfo, + handler grpc.UnaryHandler, + ) (any, error) { + switch req.(type) { + case *workflowservice.DescribeScheduleRequest, *workflowservice.UpdateScheduleRequest: + scheduleRequests.Add(1) + } + return handler(ctx, req) + }) + + for _, tc := range []struct { + name string + args []string + errorContains string + }{ + {name: "enum", args: []string{"--overlap-policy=not-a-policy"}, errorContains: "invalid argument"}, + {name: "duration", args: []string{"--catchup-window=not-a-duration"}, errorContains: "invalid duration"}, + {name: "integer", args: []string{"--remaining-actions=not-an-int"}, errorContains: "invalid argument"}, + {name: "boolean", args: []string{"--paused=not-a-bool"}, errorContains: "invalid argument"}, + {name: "timestamp", args: []string{"--start-time=not-a-timestamp"}, errorContains: "cannot parse"}, + {name: "jitter duration", args: []string{"--jitter=not-a-duration"}, errorContains: "invalid duration"}, + } { + s.T().Run(tc.name, func(t *testing.T) { + scheduleRequests.Store(0) + var failures []error + options := s.CommandHarness.Options + options.Args = append([]string{"schedule", "patch", "--schedule-id", "schedule-id"}, tc.args...) + options.Fail = func(err error) { failures = append(failures, err) } + temporalcli.Execute(context.Background(), options) + assert.NotEmpty(t, failures) + assert.ErrorContains(t, failures[0], tc.errorContains) + assert.Equal(t, int32(0), scheduleRequests.Load()) + }) + } +} + +func (s *SharedServerSuite) TestSchedule_PatchRejectsDescribeResponseWithoutSchedule() { + var describeRequests atomic.Int32 + var updateRequests atomic.Int32 + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + switch req.(type) { + case *workflowservice.DescribeScheduleRequest: + describeRequests.Add(1) + reply.(*workflowservice.DescribeScheduleResponse).Schedule = nil + return nil + case *workflowservice.UpdateScheduleRequest: + updateRequests.Add(1) + return nil + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + assert.NotPanics(s.T(), func() { + res := s.Execute( + "schedule", "patch", + "--address", s.Address(), + "--schedule-id", "missing-schedule", + "--notes", "updated notes", + ) + assert.ErrorContains(s.T(), res.Err, `DescribeSchedule response for Schedule "missing-schedule" did not contain a Schedule`) + }) + assert.Equal(s.T(), int32(1), describeRequests.Load()) + assert.Equal(s.T(), int32(0), updateRequests.Load()) +} + +func (s *SharedServerSuite) TestSchedule_PatchSubmitsOneUpdateThatChangesOnlyNotes() { + const ( + namespace = "patch-notes-namespace" + scheduleID = "patch-notes-schedule" + identity = "patch-notes-identity" + ) + conflictToken := []byte("patch-notes-conflict-token") + describedSchedule := &schedule.Schedule{ + Spec: &schedule.ScheduleSpec{CronString: []string{"0 12 * * *"}}, + Policies: &schedule.SchedulePolicies{ + OverlapPolicy: enums.SCHEDULE_OVERLAP_POLICY_BUFFER_ALL, + CatchupWindow: durationpb.New(20 * time.Second), + PauseOnFailure: true, + }, + State: &schedule.ScheduleState{ + Notes: "existing notes", + Paused: true, + LimitedActions: true, + RemainingActions: 4, + }, + } + var lock sync.Mutex + var describeRequests []*workflowservice.DescribeScheduleRequest + var updateRequests []*workflowservice.UpdateScheduleRequest + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + switch request := req.(type) { + case *workflowservice.DescribeScheduleRequest: + lock.Lock() + describeRequests = append(describeRequests, proto.Clone(request).(*workflowservice.DescribeScheduleRequest)) + lock.Unlock() + response := reply.(*workflowservice.DescribeScheduleResponse) + response.Schedule = proto.Clone(describedSchedule).(*schedule.Schedule) + response.ConflictToken = append([]byte(nil), conflictToken...) + return nil + case *workflowservice.UpdateScheduleRequest: + lock.Lock() + updateRequests = append(updateRequests, proto.Clone(request).(*workflowservice.UpdateScheduleRequest)) + lock.Unlock() + return nil + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + requestIDs := map[string]struct{}{} + for _, tc := range []struct { + name string + notes string + }{ + {name: "different value", notes: "updated notes"}, + {name: "explicit empty string", notes: ""}, + {name: "same value", notes: "existing notes"}, + } { + s.T().Run(tc.name, func(t *testing.T) { + lock.Lock() + describeRequests = nil + updateRequests = nil + lock.Unlock() + + res := s.Execute( + "schedule", "patch", + "--address", s.Address(), + "--namespace", namespace, + "--identity", identity, + "--schedule-id", scheduleID, + "--notes", tc.notes, + ) + assert.NoError(t, res.Err) + + lock.Lock() + gotDescribeRequests := append([]*workflowservice.DescribeScheduleRequest(nil), describeRequests...) + gotUpdateRequests := append([]*workflowservice.UpdateScheduleRequest(nil), updateRequests...) + lock.Unlock() + assert.Len(t, gotDescribeRequests, 1) + assert.Len(t, gotUpdateRequests, 1) + if len(gotDescribeRequests) != 1 || len(gotUpdateRequests) != 1 { + return + } + + describeRequest := gotDescribeRequests[0] + assert.Equal(t, namespace, describeRequest.GetNamespace()) + assert.Equal(t, scheduleID, describeRequest.GetScheduleId()) + + updateRequest := gotUpdateRequests[0] + assert.Equal(t, namespace, updateRequest.GetNamespace()) + assert.Equal(t, scheduleID, updateRequest.GetScheduleId()) + assert.Equal(t, conflictToken, updateRequest.GetConflictToken()) + assert.Equal(t, identity, updateRequest.GetIdentity()) + assert.NotEmpty(t, updateRequest.GetRequestId()) + _, exists := requestIDs[updateRequest.GetRequestId()] + assert.False(t, exists) + requestIDs[updateRequest.GetRequestId()] = struct{}{} + assert.Nil(t, updateRequest.GetMemo()) + assert.Nil(t, updateRequest.GetSearchAttributes()) + + expectedSchedule := proto.Clone(describedSchedule).(*schedule.Schedule) + expectedSchedule.State.Notes = tc.notes + assert.True(t, proto.Equal(expectedSchedule, updateRequest.GetSchedule())) + }) + } +} + +func (s *SharedServerSuite) TestSchedule_PatchSetsWorkflowIdentityIndependently() { + describedSchedule := &schedule.Schedule{ + Action: &schedule.ScheduleAction{Action: &schedule.ScheduleAction_StartWorkflow{ + StartWorkflow: &workflowpb.NewWorkflowExecutionInfo{ + WorkflowId: "existing-workflow-id", + WorkflowType: &commonpb.WorkflowType{Name: "ExistingWorkflow"}, + TaskQueue: &taskqueuepb.TaskQueue{ + Name: "existing-task-queue", + Kind: enums.TASK_QUEUE_KIND_STICKY, + NormalName: "existing-normal-name", + }, + WorkflowExecutionTimeout: durationpb.New(time.Hour), + WorkflowRunTimeout: durationpb.New(30 * time.Minute), + WorkflowTaskTimeout: durationpb.New(5 * time.Second), + }, + }}, + Spec: &schedule.ScheduleSpec{CronString: []string{"0 12 * * *"}}, + State: &schedule.ScheduleState{Notes: "preserved notes"}, + } + var describeCount int + var updateRequests []*workflowservice.UpdateScheduleRequest + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + switch request := req.(type) { + case *workflowservice.DescribeScheduleRequest: + describeCount++ + response := reply.(*workflowservice.DescribeScheduleResponse) + response.Schedule = proto.Clone(describedSchedule).(*schedule.Schedule) + response.ConflictToken = []byte("identity-conflict-token") + return nil + case *workflowservice.UpdateScheduleRequest: + updateRequests = append(updateRequests, proto.Clone(request).(*workflowservice.UpdateScheduleRequest)) + return nil + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + for _, tc := range []struct { + name string + args []string + apply func(*workflowpb.NewWorkflowExecutionInfo) + }{ + {name: "workflow ID", args: []string{"--workflow-id", "updated-workflow-id"}, apply: func(action *workflowpb.NewWorkflowExecutionInfo) { action.WorkflowId = "updated-workflow-id" }}, + {name: "workflow type", args: []string{"--type", "UpdatedWorkflow"}, apply: func(action *workflowpb.NewWorkflowExecutionInfo) { action.WorkflowType.Name = "UpdatedWorkflow" }}, + {name: "workflow type name alias", args: []string{"--name", "AliasedWorkflow"}, apply: func(action *workflowpb.NewWorkflowExecutionInfo) { action.WorkflowType.Name = "AliasedWorkflow" }}, + {name: "task queue", args: []string{"--task-queue", "updated-task-queue"}, apply: func(action *workflowpb.NewWorkflowExecutionInfo) { action.TaskQueue.Name = "updated-task-queue" }}, + } { + s.T().Run(tc.name, func(t *testing.T) { + describeCount = 0 + updateRequests = nil + res := s.Execute(append([]string{"schedule", "patch", "--address", s.Address(), "--schedule-id", "schedule-id"}, tc.args...)...) + assert.NoError(t, res.Err) + assert.Equal(t, 1, describeCount) + assert.Len(t, updateRequests, 1) + if len(updateRequests) != 1 { + return + } + expected := proto.Clone(describedSchedule).(*schedule.Schedule) + tc.apply(expected.GetAction().GetStartWorkflow()) + assert.True(t, proto.Equal(expected, updateRequests[0].GetSchedule())) + }) + } + + describedSchedule = &schedule.Schedule{Spec: &schedule.ScheduleSpec{CronString: []string{"0 12 * * *"}}} + describeCount = 0 + updateRequests = nil + res := s.Execute("schedule", "patch", "--address", s.Address(), "--schedule-id", "schedule-id", "--workflow-id", "updated-workflow-id") + s.EqualError(res.Err, "schedule action does not contain a StartWorkflow action") + s.Equal(1, describeCount) + s.Empty(updateRequests) +} + +func (s *SharedServerSuite) TestSchedule_PatchRejectsExactlyEmptyIdentityAndPreservesWhitespace() { + dialErr := errors.New("unexpected gRPC dial") + for _, tc := range []struct { + name string + args []string + errorContains string + }{ + {name: "empty workflow ID", args: []string{"--workflow-id="}, errorContains: "workflow ID must not be empty"}, + {name: "empty workflow type", args: []string{"--type="}, errorContains: "workflow type must not be empty"}, + {name: "empty task queue", args: []string{"--task-queue="}, errorContains: "task queue must not be empty"}, + } { + s.T().Run(tc.name, func(t *testing.T) { + var dialAttempts atomic.Int32 + options := s.CommandHarness.Options + options.Args = append([]string{"schedule", "patch", "--schedule-id", "schedule-id"}, tc.args...) + options.AdditionalClientGRPCDialOptions = append( + options.AdditionalClientGRPCDialOptions, + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { + dialAttempts.Add(1) + return nil, dialErr + }), + ) + var commandErr error + options.Fail = func(err error) { commandErr = err } + + temporalcli.Execute(context.Background(), options) + + assert.ErrorContains(t, commandErr, tc.errorContains) + assert.Equal(t, int32(0), dialAttempts.Load()) + assert.NotErrorIs(t, commandErr, dialErr) + }) + } + + describedSchedule := &schedule.Schedule{Action: &schedule.ScheduleAction{Action: &schedule.ScheduleAction_StartWorkflow{ + StartWorkflow: &workflowpb.NewWorkflowExecutionInfo{ + WorkflowId: "existing-workflow-id", + WorkflowType: &commonpb.WorkflowType{Name: "ExistingWorkflow"}, + TaskQueue: &taskqueuepb.TaskQueue{Name: "existing-task-queue"}, + }, + }}} + var updateRequests []*workflowservice.UpdateScheduleRequest + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + switch request := req.(type) { + case *workflowservice.DescribeScheduleRequest: + reply.(*workflowservice.DescribeScheduleResponse).Schedule = proto.Clone(describedSchedule).(*schedule.Schedule) + return nil + case *workflowservice.UpdateScheduleRequest: + updateRequests = append(updateRequests, proto.Clone(request).(*workflowservice.UpdateScheduleRequest)) + return nil + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + for _, tc := range []struct { + name string + args []string + value func(*workflowpb.NewWorkflowExecutionInfo) string + }{ + {name: "workflow ID whitespace", args: []string{"--workflow-id", " \t "}, value: func(action *workflowpb.NewWorkflowExecutionInfo) string { return action.GetWorkflowId() }}, + {name: "workflow type whitespace", args: []string{"--type", " \t "}, value: func(action *workflowpb.NewWorkflowExecutionInfo) string { return action.GetWorkflowType().GetName() }}, + {name: "task queue whitespace", args: []string{"--task-queue", " \t "}, value: func(action *workflowpb.NewWorkflowExecutionInfo) string { return action.GetTaskQueue().GetName() }}, + } { + s.T().Run(tc.name, func(t *testing.T) { + updateRequests = nil + res := s.Execute(append([]string{"schedule", "patch", "--address", s.Address(), "--schedule-id", "schedule-id"}, tc.args...)...) + assert.NoError(t, res.Err) + assert.Len(t, updateRequests, 1) + if len(updateRequests) == 1 { + assert.Equal(t, " \t ", tc.value(updateRequests[0].GetSchedule().GetAction().GetStartWorkflow())) + } + }) + } +} + +func (s *SharedServerSuite) TestSchedule_PatchSetsPositiveWorkflowTimeoutsIndependently() { + describedSchedule := &schedule.Schedule{ + Action: &schedule.ScheduleAction{Action: &schedule.ScheduleAction_StartWorkflow{ + StartWorkflow: &workflowpb.NewWorkflowExecutionInfo{ + WorkflowId: "workflow-id", + WorkflowType: &commonpb.WorkflowType{Name: "Workflow"}, + TaskQueue: &taskqueuepb.TaskQueue{Name: "task-queue"}, + WorkflowExecutionTimeout: durationpb.New(time.Hour), + WorkflowRunTimeout: durationpb.New(30 * time.Minute), + WorkflowTaskTimeout: durationpb.New(5 * time.Second), + }, + }}, + Spec: &schedule.ScheduleSpec{CronString: []string{"0 12 * * *"}}, + State: &schedule.ScheduleState{Notes: "preserved notes"}, + } + var updateRequests []*workflowservice.UpdateScheduleRequest + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + switch request := req.(type) { + case *workflowservice.DescribeScheduleRequest: + reply.(*workflowservice.DescribeScheduleResponse).Schedule = proto.Clone(describedSchedule).(*schedule.Schedule) + return nil + case *workflowservice.UpdateScheduleRequest: + updateRequests = append(updateRequests, proto.Clone(request).(*workflowservice.UpdateScheduleRequest)) + return nil + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + for _, tc := range []struct { + name string + flag string + value time.Duration + apply func(*workflowpb.NewWorkflowExecutionInfo, *durationpb.Duration) + }{ + {name: "execution timeout", flag: "--execution-timeout", value: 2 * time.Hour, apply: func(action *workflowpb.NewWorkflowExecutionInfo, value *durationpb.Duration) { + action.WorkflowExecutionTimeout = value + }}, + {name: "run timeout", flag: "--run-timeout", value: 45 * time.Minute, apply: func(action *workflowpb.NewWorkflowExecutionInfo, value *durationpb.Duration) { + action.WorkflowRunTimeout = value + }}, + {name: "task timeout", flag: "--task-timeout", value: 7 * time.Second, apply: func(action *workflowpb.NewWorkflowExecutionInfo, value *durationpb.Duration) { + action.WorkflowTaskTimeout = value + }}, + } { + s.T().Run(tc.name, func(t *testing.T) { + updateRequests = nil + res := s.Execute("schedule", "patch", "--address", s.Address(), "--schedule-id", "schedule-id", tc.flag, tc.value.String()) + assert.NoError(t, res.Err) + assert.Len(t, updateRequests, 1) + if len(updateRequests) != 1 { + return + } + expected := proto.Clone(describedSchedule).(*schedule.Schedule) + tc.apply(expected.GetAction().GetStartWorkflow(), durationpb.New(tc.value)) + assert.True(t, proto.Equal(expected, updateRequests[0].GetSchedule())) + }) + } +} + +func (s *SharedServerSuite) TestSchedule_PatchSetsExplicitZeroWorkflowTimeoutsAsPresent() { + describedSchedule := &schedule.Schedule{Action: &schedule.ScheduleAction{Action: &schedule.ScheduleAction_StartWorkflow{ + StartWorkflow: &workflowpb.NewWorkflowExecutionInfo{ + WorkflowId: "workflow-id", + WorkflowType: &commonpb.WorkflowType{Name: "Workflow"}, + TaskQueue: &taskqueuepb.TaskQueue{Name: "task-queue"}, + }, + }}} + var updateRequests []*workflowservice.UpdateScheduleRequest + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + switch request := req.(type) { + case *workflowservice.DescribeScheduleRequest: + reply.(*workflowservice.DescribeScheduleResponse).Schedule = proto.Clone(describedSchedule).(*schedule.Schedule) + return nil + case *workflowservice.UpdateScheduleRequest: + updateRequests = append(updateRequests, proto.Clone(request).(*workflowservice.UpdateScheduleRequest)) + return nil + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + for _, tc := range []struct { + name string + flag string + get func(*workflowpb.NewWorkflowExecutionInfo) *durationpb.Duration + }{ + {name: "execution timeout", flag: "--execution-timeout", get: func(action *workflowpb.NewWorkflowExecutionInfo) *durationpb.Duration { + return action.GetWorkflowExecutionTimeout() + }}, + {name: "run timeout", flag: "--run-timeout", get: func(action *workflowpb.NewWorkflowExecutionInfo) *durationpb.Duration { + return action.GetWorkflowRunTimeout() + }}, + {name: "task timeout", flag: "--task-timeout", get: func(action *workflowpb.NewWorkflowExecutionInfo) *durationpb.Duration { + return action.GetWorkflowTaskTimeout() + }}, + } { + s.T().Run(tc.name, func(t *testing.T) { + updateRequests = nil + res := s.Execute("schedule", "patch", "--address", s.Address(), "--schedule-id", "schedule-id", tc.flag, "0s") + assert.NoError(t, res.Err) + assert.Len(t, updateRequests, 1) + if len(updateRequests) != 1 { + return + } + got := tc.get(updateRequests[0].GetSchedule().GetAction().GetStartWorkflow()) + assert.NotNil(t, got) + if got != nil { + assert.Equal(t, time.Duration(0), got.AsDuration()) + } + }) + } +} + +func (s *SharedServerSuite) TestSchedule_PatchResetsWorkflowTimeoutsToDistinctDefaults() { + describedSchedule := &schedule.Schedule{ + Action: &schedule.ScheduleAction{Action: &schedule.ScheduleAction_StartWorkflow{ + StartWorkflow: &workflowpb.NewWorkflowExecutionInfo{ + WorkflowId: "workflow-id", + WorkflowType: &commonpb.WorkflowType{Name: "Workflow"}, + TaskQueue: &taskqueuepb.TaskQueue{Name: "task-queue"}, + WorkflowExecutionTimeout: durationpb.New(time.Hour), + WorkflowRunTimeout: durationpb.New(30 * time.Minute), + WorkflowTaskTimeout: durationpb.New(5 * time.Second), + }, + }}, + State: &schedule.ScheduleState{Notes: "preserved notes"}, + } + var updateRequests []*workflowservice.UpdateScheduleRequest + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + switch request := req.(type) { + case *workflowservice.DescribeScheduleRequest: + reply.(*workflowservice.DescribeScheduleResponse).Schedule = proto.Clone(describedSchedule).(*schedule.Schedule) + return nil + case *workflowservice.UpdateScheduleRequest: + updateRequests = append(updateRequests, proto.Clone(request).(*workflowservice.UpdateScheduleRequest)) + return nil + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + for _, tc := range []struct { + name string + flag string + apply func(*workflowpb.NewWorkflowExecutionInfo) + }{ + {name: "execution timeout", flag: "--unset-execution-timeout", apply: func(action *workflowpb.NewWorkflowExecutionInfo) { action.WorkflowExecutionTimeout = nil }}, + {name: "run timeout", flag: "--unset-run-timeout", apply: func(action *workflowpb.NewWorkflowExecutionInfo) { action.WorkflowRunTimeout = nil }}, + {name: "task timeout", flag: "--unset-task-timeout", apply: func(action *workflowpb.NewWorkflowExecutionInfo) { + action.WorkflowTaskTimeout = durationpb.New(10 * time.Second) + }}, + } { + s.T().Run(tc.name, func(t *testing.T) { + updateRequests = nil + res := s.Execute("schedule", "patch", "--address", s.Address(), "--schedule-id", "schedule-id", tc.flag) + assert.NoError(t, res.Err) + assert.Len(t, updateRequests, 1) + if len(updateRequests) != 1 { + return + } + expected := proto.Clone(describedSchedule).(*schedule.Schedule) + tc.apply(expected.GetAction().GetStartWorkflow()) + assert.True(t, proto.Equal(expected, updateRequests[0].GetSchedule())) + }) + } +} + +func (s *SharedServerSuite) TestSchedule_PatchRejectsTimeoutConflictsAndNegativesBeforeDial() { + dialErr := errors.New("unexpected gRPC dial") + for _, tc := range []struct { + name string + args []string + errorContains string + }{ + {name: "execution timeout conflict", args: []string{"--execution-timeout", "1s", "--unset-execution-timeout"}, errorContains: "--execution-timeout and --unset-execution-timeout are mutually exclusive"}, + {name: "run timeout conflict", args: []string{"--run-timeout", "1s", "--unset-run-timeout"}, errorContains: "--run-timeout and --unset-run-timeout are mutually exclusive"}, + {name: "task timeout conflict", args: []string{"--task-timeout", "1s", "--unset-task-timeout"}, errorContains: "--task-timeout and --unset-task-timeout are mutually exclusive"}, + {name: "negative execution timeout", args: []string{"--execution-timeout", "-1s"}, errorContains: "execution timeout must not be negative"}, + {name: "negative run timeout", args: []string{"--run-timeout", "-1s"}, errorContains: "run timeout must not be negative"}, + {name: "negative task timeout", args: []string{"--task-timeout", "-1s"}, errorContains: "task timeout must not be negative"}, + {name: "static summary conflict", args: []string{"--static-summary", "summary", "--unset-static-summary"}, errorContains: "--static-summary and --unset-static-summary are mutually exclusive"}, + {name: "static details conflict", args: []string{"--static-details", "details", "--unset-static-details"}, errorContains: "--static-details and --unset-static-details are mutually exclusive"}, + } { + s.T().Run(tc.name, func(t *testing.T) { + var dialAttempts atomic.Int32 + options := s.CommandHarness.Options + options.Args = append([]string{"schedule", "patch", "--schedule-id", "schedule-id"}, tc.args...) + options.AdditionalClientGRPCDialOptions = append( + options.AdditionalClientGRPCDialOptions, + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { + dialAttempts.Add(1) + return nil, dialErr + }), + ) + var commandErr error + options.Fail = func(err error) { commandErr = err } + + temporalcli.Execute(context.Background(), options) + + assert.ErrorContains(t, commandErr, tc.errorContains) + assert.Equal(t, int32(0), dialAttempts.Load()) + assert.NotErrorIs(t, commandErr, dialErr) + }) + } +} + +func (s *SharedServerSuite) TestSchedule_PatchSetsStaticMetadataIndependently() { + existingSummary, err := temporalcli.DataConverterWithRawValue.ToPayload("existing summary") + s.NoError(err) + existingDetails, err := temporalcli.DataConverterWithRawValue.ToPayload("existing details") + s.NoError(err) + metadata := &sdkpb.UserMetadata{Summary: existingSummary, Details: existingDetails} + metadata.ProtoReflect().SetUnknown([]byte{0xa0, 0x06, 0x01}) + describedSchedule := &schedule.Schedule{Action: &schedule.ScheduleAction{Action: &schedule.ScheduleAction_StartWorkflow{ + StartWorkflow: &workflowpb.NewWorkflowExecutionInfo{ + WorkflowId: "workflow-id", + WorkflowType: &commonpb.WorkflowType{Name: "Workflow"}, + TaskQueue: &taskqueuepb.TaskQueue{Name: "task-queue"}, + UserMetadata: metadata, + }, + }}} + var updateRequests []*workflowservice.UpdateScheduleRequest + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + switch request := req.(type) { + case *workflowservice.DescribeScheduleRequest: + reply.(*workflowservice.DescribeScheduleResponse).Schedule = proto.Clone(describedSchedule).(*schedule.Schedule) + return nil + case *workflowservice.UpdateScheduleRequest: + updateRequests = append(updateRequests, proto.Clone(request).(*workflowservice.UpdateScheduleRequest)) + return nil + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + for _, tc := range []struct { + name string + flag string + value string + apply func(*sdkpb.UserMetadata, *commonpb.Payload) + }{ + {name: "summary", flag: "--static-summary", value: "updated summary", apply: func(metadata *sdkpb.UserMetadata, value *commonpb.Payload) { metadata.Summary = value }}, + {name: "details", flag: "--static-details", value: "updated details", apply: func(metadata *sdkpb.UserMetadata, value *commonpb.Payload) { metadata.Details = value }}, + {name: "explicit empty summary", flag: "--static-summary", value: "", apply: func(metadata *sdkpb.UserMetadata, value *commonpb.Payload) { metadata.Summary = value }}, + } { + s.T().Run(tc.name, func(t *testing.T) { + updateRequests = nil + res := s.Execute("schedule", "patch", "--address", s.Address(), "--schedule-id", "schedule-id", tc.flag, tc.value) + assert.NoError(t, res.Err) + assert.Len(t, updateRequests, 1) + if len(updateRequests) != 1 { + return + } + expectedPayload, payloadErr := temporalcli.DataConverterWithRawValue.ToPayload(tc.value) + assert.NoError(t, payloadErr) + expected := proto.Clone(describedSchedule).(*schedule.Schedule) + tc.apply(expected.GetAction().GetStartWorkflow().GetUserMetadata(), expectedPayload) + assert.True(t, proto.Equal(expected, updateRequests[0].GetSchedule())) + actualMetadata := updateRequests[0].GetSchedule().GetAction().GetStartWorkflow().GetUserMetadata() + assert.True(t, bytes.Equal([]byte{0xa0, 0x06, 0x01}, actualMetadata.ProtoReflect().GetUnknown())) + if tc.value == "" { + assert.Equal(t, "json/plain", string(expectedPayload.GetMetadata()["encoding"])) + assert.Equal(t, []byte(`""`), expectedPayload.GetData()) + } + }) + } +} + +func (s *SharedServerSuite) TestSchedule_PatchUnsetsStaticMetadataIndependently() { + existingSummary, err := temporalcli.DataConverterWithRawValue.ToPayload("existing summary") + s.NoError(err) + existingDetails, err := temporalcli.DataConverterWithRawValue.ToPayload("existing details") + s.NoError(err) + var describedSchedule *schedule.Schedule + var updateRequests []*workflowservice.UpdateScheduleRequest + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + switch request := req.(type) { + case *workflowservice.DescribeScheduleRequest: + reply.(*workflowservice.DescribeScheduleResponse).Schedule = proto.Clone(describedSchedule).(*schedule.Schedule) + return nil + case *workflowservice.UpdateScheduleRequest: + updateRequests = append(updateRequests, proto.Clone(request).(*workflowservice.UpdateScheduleRequest)) + return nil + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + for _, tc := range []struct { + name string + flag string + metadata *sdkpb.UserMetadata + apply func(*sdkpb.UserMetadata) + }{ + {name: "summary", flag: "--unset-static-summary", metadata: &sdkpb.UserMetadata{Summary: existingSummary, Details: existingDetails}, apply: func(metadata *sdkpb.UserMetadata) { metadata.Summary = nil }}, + {name: "details", flag: "--unset-static-details", metadata: &sdkpb.UserMetadata{Summary: existingSummary, Details: existingDetails}, apply: func(metadata *sdkpb.UserMetadata) { metadata.Details = nil }}, + {name: "absent metadata", flag: "--unset-static-summary", metadata: nil, apply: func(*sdkpb.UserMetadata) {}}, + } { + s.T().Run(tc.name, func(t *testing.T) { + if tc.metadata != nil { + tc.metadata.ProtoReflect().SetUnknown([]byte{0xa0, 0x06, 0x01}) + } + describedSchedule = &schedule.Schedule{Action: &schedule.ScheduleAction{Action: &schedule.ScheduleAction_StartWorkflow{ + StartWorkflow: &workflowpb.NewWorkflowExecutionInfo{ + WorkflowId: "workflow-id", + WorkflowType: &commonpb.WorkflowType{Name: "Workflow"}, + TaskQueue: &taskqueuepb.TaskQueue{Name: "task-queue"}, + UserMetadata: tc.metadata, + }, + }}} + updateRequests = nil + + res := s.Execute("schedule", "patch", "--address", s.Address(), "--schedule-id", "schedule-id", tc.flag) + assert.NoError(t, res.Err) + assert.Len(t, updateRequests, 1) + if len(updateRequests) != 1 { + return + } + expected := proto.Clone(describedSchedule).(*schedule.Schedule) + tc.apply(expected.GetAction().GetStartWorkflow().GetUserMetadata()) + assert.True(t, proto.Equal(expected, updateRequests[0].GetSchedule())) + }) + } +} + +func (s *SharedServerSuite) TestSchedule_PatchReappliesStaticSummaryAfterConflictRefresh() { + staleSummary, err := temporalcli.DataConverterWithRawValue.ToPayload("stale summary") + s.NoError(err) + staleDetails, err := temporalcli.DataConverterWithRawValue.ToPayload("stale details") + s.NoError(err) + refreshedSummary, err := temporalcli.DataConverterWithRawValue.ToPayload("refreshed summary") + s.NoError(err) + refreshedDetails, err := temporalcli.DataConverterWithRawValue.ToPayload("refreshed details") + s.NoError(err) + describedSchedules := []*schedule.Schedule{ + {Action: &schedule.ScheduleAction{Action: &schedule.ScheduleAction_StartWorkflow{StartWorkflow: &workflowpb.NewWorkflowExecutionInfo{ + WorkflowId: "stale-workflow-id", + WorkflowType: &commonpb.WorkflowType{Name: "StaleWorkflow"}, + TaskQueue: &taskqueuepb.TaskQueue{Name: "stale-task-queue"}, + UserMetadata: &sdkpb.UserMetadata{Summary: staleSummary, Details: staleDetails}, + }}}}, + {Action: &schedule.ScheduleAction{Action: &schedule.ScheduleAction_StartWorkflow{StartWorkflow: &workflowpb.NewWorkflowExecutionInfo{ + WorkflowId: "refreshed-workflow-id", + WorkflowType: &commonpb.WorkflowType{Name: "RefreshedWorkflow"}, + TaskQueue: &taskqueuepb.TaskQueue{Name: "refreshed-task-queue"}, + UserMetadata: &sdkpb.UserMetadata{Summary: refreshedSummary, Details: refreshedDetails}, + }}}}, + } + conflictTokens := [][]byte{[]byte("stale-token"), []byte("refreshed-token")} + var describeCount int + var updateRequests []*workflowservice.UpdateScheduleRequest + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + switch request := req.(type) { + case *workflowservice.DescribeScheduleRequest: + response := reply.(*workflowservice.DescribeScheduleResponse) + response.Schedule = proto.Clone(describedSchedules[describeCount]).(*schedule.Schedule) + response.ConflictToken = append([]byte(nil), conflictTokens[describeCount]...) + describeCount++ + return nil + case *workflowservice.UpdateScheduleRequest: + updateRequests = append(updateRequests, proto.Clone(request).(*workflowservice.UpdateScheduleRequest)) + if len(updateRequests) == 1 { + return status.Error(codes.FailedPrecondition, "mismatched conflict token") + } + return nil + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + res := s.Execute("schedule", "patch", "--address", s.Address(), "--schedule-id", "schedule-id", "--static-summary", "requested summary") + s.NoError(res.Err) + s.Equal(2, describeCount) + s.Len(updateRequests, 2) + if len(updateRequests) != 2 { + return + } + requestedSummary, err := temporalcli.DataConverterWithRawValue.ToPayload("requested summary") + s.NoError(err) + for index, request := range updateRequests { + expected := proto.Clone(describedSchedules[index]).(*schedule.Schedule) + expected.GetAction().GetStartWorkflow().UserMetadata.Summary = requestedSummary + s.True(proto.Equal(expected, request.GetSchedule())) + s.Equal(conflictTokens[index], request.GetConflictToken()) + s.NotEmpty(request.GetRequestId()) + } + s.NotEqual(updateRequests[0].GetRequestId(), updateRequests[1].GetRequestId()) + s.Equal("refreshed-task-queue", updateRequests[1].GetSchedule().GetAction().GetStartWorkflow().GetTaskQueue().GetName()) + s.True(proto.Equal(refreshedDetails, updateRequests[1].GetSchedule().GetAction().GetStartWorkflow().GetUserMetadata().GetDetails())) +} + +func (s *SharedServerSuite) TestSchedule_PatchSetsWorkflowFieldsAndRestoresOptionalDefaults() { + scheduleID, _, res := s.createSchedule("--interval", "10d") + s.NoError(res.Err) + + res = s.Execute( + "schedule", "patch", + "--address", s.Address(), + "--schedule-id", scheduleID, + "--workflow-id", "patched-workflow-id", + "--type", "PatchedWorkflow", + "--task-queue", "patched-task-queue", + "--execution-timeout", "2h", + "--run-timeout", "1h", + "--task-timeout", "7s", + "--static-summary", "patched summary", + "--static-details", "patched details", + ) + s.NoError(res.Err) + + expectedSummary, err := temporalcli.DataConverterWithRawValue.ToPayload("patched summary") + s.NoError(err) + expectedDetails, err := temporalcli.DataConverterWithRawValue.ToPayload("patched details") + s.NoError(err) + s.Eventually(func() bool { + res = s.Execute("schedule", "describe", "--address", s.Address(), "--schedule-id", scheduleID, "--output", "json") + if res.Err != nil { + return false + } + var description workflowservice.DescribeScheduleResponse + if err := temporalcli.UnmarshalProtoJSONWithOptions(res.Stdout.Bytes(), &description, true); err != nil { + return false + } + action := description.GetSchedule().GetAction().GetStartWorkflow() + return action.GetWorkflowId() == "patched-workflow-id" && + action.GetWorkflowType().GetName() == "PatchedWorkflow" && + action.GetTaskQueue().GetName() == "patched-task-queue" && + action.GetWorkflowExecutionTimeout().AsDuration() == 2*time.Hour && + action.GetWorkflowRunTimeout().AsDuration() == time.Hour && + action.GetWorkflowTaskTimeout().AsDuration() == 7*time.Second && + proto.Equal(expectedSummary, action.GetUserMetadata().GetSummary()) && + proto.Equal(expectedDetails, action.GetUserMetadata().GetDetails()) + }, 10*time.Second, 100*time.Millisecond) + + res = s.Execute( + "schedule", "patch", + "--address", s.Address(), + "--schedule-id", scheduleID, + "--unset-execution-timeout", + "--unset-run-timeout", + "--unset-task-timeout", + "--unset-static-summary", + "--unset-static-details", + ) + s.NoError(res.Err) + + s.Eventually(func() bool { + res = s.Execute("schedule", "describe", "--address", s.Address(), "--schedule-id", scheduleID, "--output", "json") + if res.Err != nil { + return false + } + var description workflowservice.DescribeScheduleResponse + if err := temporalcli.UnmarshalProtoJSONWithOptions(res.Stdout.Bytes(), &description, true); err != nil { + return false + } + action := description.GetSchedule().GetAction().GetStartWorkflow() + return action.GetWorkflowId() == "patched-workflow-id" && + action.GetWorkflowType().GetName() == "PatchedWorkflow" && + action.GetTaskQueue().GetName() == "patched-task-queue" && + action.GetWorkflowExecutionTimeout() == nil && + action.GetWorkflowRunTimeout() == nil && + action.GetWorkflowTaskTimeout().AsDuration() == 10*time.Second && + action.GetUserMetadata().GetSummary() == nil && + action.GetUserMetadata().GetDetails() == nil + }, 10*time.Second, 100*time.Millisecond) +} + +func (s *SharedServerSuite) TestSchedule_PatchReplacesScheduleSpecSourceGroups() { + const scheduleID = "patch-spec-sources-schedule" + describedSchedule := &schedule.Schedule{ + Spec: &schedule.ScheduleSpec{ + StructuredCalendar: []*schedule.StructuredCalendarSpec{{}}, + Calendar: []*schedule.CalendarSpec{{Minute: "1"}}, + CronString: []string{"0 12 * * *"}, + Interval: []*schedule.IntervalSpec{{Interval: durationpb.New(time.Hour)}}, + ExcludeCalendar: []*schedule.CalendarSpec{{Minute: "2"}}, + ExcludeStructuredCalendar: []*schedule.StructuredCalendarSpec{{}}, + StartTime: timestamppb.New(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)), + EndTime: timestamppb.New(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)), + Jitter: durationpb.New(time.Minute), + TimezoneName: "America/New_York", + }, + Action: &schedule.ScheduleAction{}, + Policies: &schedule.SchedulePolicies{PauseOnFailure: true}, + State: &schedule.ScheduleState{Notes: "preserved", Paused: true}, + } + var lock sync.Mutex + var updateRequests []*workflowservice.UpdateScheduleRequest + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func(ctx context.Context, method string, req any, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + switch request := req.(type) { + case *workflowservice.DescribeScheduleRequest: + response := reply.(*workflowservice.DescribeScheduleResponse) + response.Schedule = proto.Clone(describedSchedule).(*schedule.Schedule) + response.ConflictToken = []byte("spec-sources-token") + return nil + case *workflowservice.UpdateScheduleRequest: + lock.Lock() + updateRequests = append(updateRequests, proto.Clone(request).(*workflowservice.UpdateScheduleRequest)) + lock.Unlock() + return nil + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + for _, tc := range []struct { + name string + args []string + cron []string + intervals []*schedule.IntervalSpec + }{ + {name: "calendar", args: []string{"--calendar", `{"minute":"5"}`}, cron: []string{"0 5 0 * * * *"}}, + {name: "cron", args: []string{"--cron", "0 5 * * *"}, cron: []string{"0 5 * * *"}}, + {name: "interval", args: []string{"--interval", "2h/30m"}, intervals: []*schedule.IntervalSpec{{Interval: durationpb.New(2 * time.Hour), Phase: durationpb.New(30 * time.Minute)}}}, + {name: "minimum interval with zero phase", args: []string{"--interval", "1s/0s"}, intervals: []*schedule.IntervalSpec{{Interval: durationpb.New(time.Second), Phase: durationpb.New(0)}}}, + {name: "phase just below interval", args: []string{"--interval", "1s/999ms"}, intervals: []*schedule.IntervalSpec{{Interval: durationpb.New(time.Second), Phase: durationpb.New(999 * time.Millisecond)}}}, + {name: "combined", args: []string{"--calendar", `{"minute":"5"}`, "--cron", "0 5 * * *", "--interval", "2h/30m"}, cron: []string{"0 5 0 * * * *", "0 5 * * *"}, intervals: []*schedule.IntervalSpec{{Interval: durationpb.New(2 * time.Hour), Phase: durationpb.New(30 * time.Minute)}}}, + {name: "same value", args: []string{"--cron", "0 12 * * *"}, cron: []string{"0 12 * * *"}}, + } { + s.T().Run(tc.name, func(t *testing.T) { + lock.Lock() + updateRequests = nil + lock.Unlock() + + res := s.Execute(append([]string{"schedule", "patch", "--address", s.Address(), "--schedule-id", scheduleID}, tc.args...)...) + assert.NoError(t, res.Err) + + lock.Lock() + gotUpdates := append([]*workflowservice.UpdateScheduleRequest(nil), updateRequests...) + lock.Unlock() + if !assert.Len(t, gotUpdates, 1) { + return + } + expected := proto.Clone(describedSchedule).(*schedule.Schedule) + expected.Spec.StructuredCalendar = nil + expected.Spec.Calendar = nil + expected.Spec.CronString = tc.cron + expected.Spec.Interval = tc.intervals + assert.True(t, proto.Equal(expected, gotUpdates[0].GetSchedule())) + }) + } +} + +func (s *SharedServerSuite) TestSchedule_PatchPreservesScheduleSpecSourceGroupsWhenOmitted() { + describedSchedule := &schedule.Schedule{ + Spec: &schedule.ScheduleSpec{ + StructuredCalendar: []*schedule.StructuredCalendarSpec{{}}, + Calendar: []*schedule.CalendarSpec{{Minute: "1"}}, + CronString: []string{"0 12 * * *"}, + Interval: []*schedule.IntervalSpec{{Interval: durationpb.New(time.Hour)}}, + ExcludeCalendar: []*schedule.CalendarSpec{{Minute: "2"}}, + ExcludeStructuredCalendar: []*schedule.StructuredCalendarSpec{{}}, + TimezoneName: "America/New_York", + }, + State: &schedule.ScheduleState{Notes: "before"}, + } + var updateRequest *workflowservice.UpdateScheduleRequest + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func(ctx context.Context, method string, req any, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + switch request := req.(type) { + case *workflowservice.DescribeScheduleRequest: + response := reply.(*workflowservice.DescribeScheduleResponse) + response.Schedule = proto.Clone(describedSchedule).(*schedule.Schedule) + return nil + case *workflowservice.UpdateScheduleRequest: + updateRequest = proto.Clone(request).(*workflowservice.UpdateScheduleRequest) + return nil + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + res := s.Execute("schedule", "patch", "--address", s.Address(), "--schedule-id", "patch-omit-spec-sources", "--notes", "after") + s.NoError(res.Err) + if !assert.NotNil(s.T(), updateRequest) { + return + } + expected := proto.Clone(describedSchedule).(*schedule.Schedule) + expected.State.Notes = "after" + s.True(proto.Equal(expected, updateRequest.GetSchedule())) + describedSpec, err := proto.Marshal(describedSchedule.GetSpec()) + s.NoError(err) + submittedSpec, err := proto.Marshal(updateRequest.GetSchedule().GetSpec()) + s.NoError(err) + s.Equal(describedSpec, submittedSpec) +} + +func (s *SharedServerSuite) TestSchedule_PatchClearsScheduleSpecSourcesOnlyForPausedResult() { + var describedSchedule *schedule.Schedule + var updateRequest *workflowservice.UpdateScheduleRequest + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func(ctx context.Context, method string, req any, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + switch request := req.(type) { + case *workflowservice.DescribeScheduleRequest: + response := reply.(*workflowservice.DescribeScheduleResponse) + response.Schedule = proto.Clone(describedSchedule).(*schedule.Schedule) + return nil + case *workflowservice.UpdateScheduleRequest: + updateRequest = proto.Clone(request).(*workflowservice.UpdateScheduleRequest) + return nil + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + for _, tc := range []struct { + name string + paused bool + args []string + }{ + {name: "already paused", paused: true}, + {name: "same patch pauses", paused: false, args: []string{"--paused=true"}}, + } { + s.T().Run(tc.name, func(t *testing.T) { + describedSchedule = &schedule.Schedule{ + Spec: &schedule.ScheduleSpec{ + StructuredCalendar: []*schedule.StructuredCalendarSpec{{}}, + Calendar: []*schedule.CalendarSpec{{Minute: "1"}}, + CronString: []string{"0 12 * * *"}, + Interval: []*schedule.IntervalSpec{{Interval: durationpb.New(time.Hour)}}, + ExcludeCalendar: []*schedule.CalendarSpec{{Minute: "2"}}, + ExcludeStructuredCalendar: []*schedule.StructuredCalendarSpec{{}}, + StartTime: timestamppb.New(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)), + EndTime: timestamppb.New(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)), + Jitter: durationpb.New(time.Minute), + TimezoneName: "America/New_York", + }, + State: &schedule.ScheduleState{Paused: tc.paused}, + } + updateRequest = nil + + args := append([]string{"schedule", "patch", "--address", s.Address(), "--schedule-id", "patch-clear-spec-sources", "--spec-clear-all"}, tc.args...) + res := s.Execute(args...) + assert.NoError(t, res.Err) + if !assert.NotNil(t, updateRequest) { + return + } + expected := proto.Clone(describedSchedule).(*schedule.Schedule) + expected.Spec.StructuredCalendar = nil + expected.Spec.Calendar = nil + expected.Spec.CronString = nil + expected.Spec.Interval = nil + if !tc.paused { + expected.State.Paused = true + } + assert.True(t, proto.Equal(expected, updateRequest.GetSchedule())) + }) + } +} + +func (s *SharedServerSuite) TestSchedule_PatchRejectsScheduleSpecClearWhenResultIsUnpaused() { + var updateRequests atomic.Int32 + var describedState *schedule.ScheduleState + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func(ctx context.Context, method string, req any, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + switch req.(type) { + case *workflowservice.DescribeScheduleRequest: + response := reply.(*workflowservice.DescribeScheduleResponse) + response.Schedule = &schedule.Schedule{State: describedState} + return nil + case *workflowservice.UpdateScheduleRequest: + updateRequests.Add(1) + return nil + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + for _, tc := range []struct { + name string + state *schedule.ScheduleState + args []string + }{ + {name: "schedule is active", state: &schedule.ScheduleState{Paused: false}, args: []string{"--spec-clear-all"}}, + {name: "schedule state is absent", state: nil, args: []string{"--spec-clear-all"}}, + {name: "patch explicitly unpauses", state: &schedule.ScheduleState{Paused: true}, args: []string{"--spec-clear-all", "--paused=false"}}, + } { + describedState = tc.state + res := s.Execute(append([]string{"schedule", "patch", "--address", s.Address(), "--schedule-id", "patch-unpaused-clear"}, tc.args...)...) + s.Error(res.Err, tc.name) + s.ErrorContains(res.Err, "use --paused=true to pause explicitly", tc.name) + } + s.Equal(int32(0), updateRequests.Load()) +} + +func (s *SharedServerSuite) TestSchedule_PatchClearsPausedScheduleSpecSourcesToManualOnly() { + scheduleID, workflowID, res := s.createSchedule("--interval", "10d") + s.NoError(res.Err) + + res = s.Execute("schedule", "patch", "--address", s.Address(), "--schedule-id", scheduleID, "--paused=true", "--spec-clear-all") + s.NoError(res.Err) + + var description struct { + Schedule struct { + Spec struct { + StructuredCalendar []json.RawMessage `json:"structuredCalendar"` + Calendar []json.RawMessage `json:"calendar"` + CronString []string `json:"cronString"` + Interval []json.RawMessage `json:"interval"` + } `json:"spec"` + State struct { + Paused bool `json:"paused"` + } `json:"state"` + } `json:"schedule"` + Info struct { + FutureActionTimes []json.RawMessage `json:"futureActionTimes"` + } `json:"info"` + } + s.Eventually(func() bool { + res = s.Execute("schedule", "describe", "--address", s.Address(), "--schedule-id", scheduleID, "--output", "json") + description = struct { + Schedule struct { + Spec struct { + StructuredCalendar []json.RawMessage `json:"structuredCalendar"` + Calendar []json.RawMessage `json:"calendar"` + CronString []string `json:"cronString"` + Interval []json.RawMessage `json:"interval"` + } `json:"spec"` + State struct { + Paused bool `json:"paused"` + } `json:"state"` + } `json:"schedule"` + Info struct { + FutureActionTimes []json.RawMessage `json:"futureActionTimes"` + } `json:"info"` + }{} + if res.Err != nil || json.Unmarshal(res.Stdout.Bytes(), &description) != nil { + return false + } + return description.Schedule.State.Paused && len(description.Schedule.Spec.StructuredCalendar) == 0 && len(description.Schedule.Spec.Calendar) == 0 && len(description.Schedule.Spec.CronString) == 0 && len(description.Schedule.Spec.Interval) == 0 && len(description.Info.FutureActionTimes) == 0 + }, 10*time.Second, 100*time.Millisecond) + + res = s.Execute("schedule", "trigger", "--address", s.Address(), "--schedule-id", scheduleID) + s.NoError(res.Err) + s.Eventually(func() bool { + res = s.Execute("workflow", "list", "--address", s.Address(), "-q", fmt.Sprintf(`TemporalScheduledById = "%s"`, scheduleID)) + return res.Err == nil && AssertContainsOnSameLine(res.Stdout.String(), workflowID) == nil + }, 10*time.Second, 100*time.Millisecond) +} + +func (s *SharedServerSuite) TestSchedule_PatchRevalidatesScheduleSpecClearAfterConflictRefresh() { + var describes int + var updates []*workflowservice.UpdateScheduleRequest + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func(ctx context.Context, method string, req any, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + switch request := req.(type) { + case *workflowservice.DescribeScheduleRequest: + describes++ + response := reply.(*workflowservice.DescribeScheduleResponse) + response.Schedule = &schedule.Schedule{ + Spec: &schedule.ScheduleSpec{CronString: []string{"0 12 * * *"}, Interval: []*schedule.IntervalSpec{{Interval: durationpb.New(time.Hour)}}}, + State: &schedule.ScheduleState{Paused: describes == 1}, + } + response.ConflictToken = []byte(fmt.Sprintf("token-%d", describes)) + return nil + case *workflowservice.UpdateScheduleRequest: + updates = append(updates, proto.Clone(request).(*workflowservice.UpdateScheduleRequest)) + if len(updates) == 1 { + return status.Error(codes.FailedPrecondition, "mismatched conflict token") + } + return nil + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + for _, tc := range []struct { + name string + args []string + wantErr string + wantUpdates int + }{ + {name: "refreshed unpaused fails", args: []string{"--spec-clear-all"}, wantErr: "use --paused=true to pause explicitly", wantUpdates: 1}, + {name: "explicit pause reapplies", args: []string{"--spec-clear-all", "--paused=true"}, wantUpdates: 2}, + } { + s.T().Run(tc.name, func(t *testing.T) { + describes = 0 + updates = nil + + res := s.Execute(append([]string{"schedule", "patch", "--address", s.Address(), "--schedule-id", "patch-refresh-clear"}, tc.args...)...) + if tc.wantErr != "" { + assert.ErrorContains(t, res.Err, tc.wantErr) + } else { + assert.NoError(t, res.Err) + } + assert.Equal(t, 2, describes) + assert.Len(t, updates, tc.wantUpdates) + for _, update := range updates { + assert.True(t, update.GetSchedule().GetState().GetPaused()) + assert.Empty(t, update.GetSchedule().GetSpec().GetStructuredCalendar()) + assert.Empty(t, update.GetSchedule().GetSpec().GetCalendar()) + assert.Empty(t, update.GetSchedule().GetSpec().GetCronString()) + assert.Empty(t, update.GetSchedule().GetSpec().GetInterval()) + } + if tc.wantUpdates == 2 && len(updates) == 2 { + assert.Equal(t, []byte("token-2"), updates[1].GetConflictToken()) + assert.NotEqual(t, updates[0].GetRequestId(), updates[1].GetRequestId()) + } + }) + } +} + +func (s *SharedServerSuite) TestSchedule_PatchSetsTimingFieldsIndependently() { + initialStart := timestamppb.New(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) + initialEnd := timestamppb.New(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) + describedSchedule := &schedule.Schedule{ + Spec: &schedule.ScheduleSpec{ + CronString: []string{"0 12 * * *"}, + Interval: []*schedule.IntervalSpec{{Interval: durationpb.New(time.Hour)}}, + ExcludeCalendar: []*schedule.CalendarSpec{{Minute: "2"}}, + StartTime: initialStart, + EndTime: initialEnd, + Jitter: durationpb.New(time.Minute), + TimezoneName: "America/New_York", + TimezoneData: []byte("tzif"), + }, + Action: &schedule.ScheduleAction{}, + Policies: &schedule.SchedulePolicies{PauseOnFailure: true}, + State: &schedule.ScheduleState{Notes: "preserved", Paused: true}, + } + var updateRequest *workflowservice.UpdateScheduleRequest + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func(ctx context.Context, method string, req any, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + switch request := req.(type) { + case *workflowservice.DescribeScheduleRequest: + response := reply.(*workflowservice.DescribeScheduleResponse) + response.Schedule = proto.Clone(describedSchedule).(*schedule.Schedule) + return nil + case *workflowservice.UpdateScheduleRequest: + updateRequest = proto.Clone(request).(*workflowservice.UpdateScheduleRequest) + return nil + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + for _, tc := range []struct { + name string + args []string + apply func(*schedule.Schedule) + }{ + {name: "start", args: []string{"--start-time", "2027-01-01T00:00:00Z"}, apply: func(s *schedule.Schedule) { + s.Spec.StartTime = timestamppb.New(time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC)) + }}, + {name: "end", args: []string{"--end-time", "2028-01-01T00:00:00Z"}, apply: func(s *schedule.Schedule) { + s.Spec.EndTime = timestamppb.New(time.Date(2028, 1, 1, 0, 0, 0, 0, time.UTC)) + }}, + {name: "jitter", args: []string{"--jitter", "30s"}, apply: func(s *schedule.Schedule) { s.Spec.Jitter = durationpb.New(30 * time.Second) }}, + {name: "zero jitter", args: []string{"--jitter", "0s"}, apply: func(s *schedule.Schedule) { s.Spec.Jitter = durationpb.New(0) }}, + {name: "time zone", args: []string{"--time-zone", "Asia/Tokyo"}, apply: func(s *schedule.Schedule) { s.Spec.TimezoneName, s.Spec.TimezoneData = "Asia/Tokyo", nil }}, + } { + s.T().Run(tc.name, func(t *testing.T) { + updateRequest = nil + res := s.Execute(append([]string{"schedule", "patch", "--address", s.Address(), "--schedule-id", "patch-timing"}, tc.args...)...) + assert.NoError(t, res.Err) + if !assert.NotNil(t, updateRequest) { + return + } + expected := proto.Clone(describedSchedule).(*schedule.Schedule) + tc.apply(expected) + assert.True(t, proto.Equal(expected, updateRequest.GetSchedule())) + }) + } +} + +func (s *SharedServerSuite) TestSchedule_PatchUnsetsTimingFieldsAndRejectsSetUnsetConflicts() { + describedSchedule := &schedule.Schedule{Spec: &schedule.ScheduleSpec{ + CronString: []string{"0 12 * * *"}, + Interval: []*schedule.IntervalSpec{{Interval: durationpb.New(time.Hour)}}, + ExcludeCalendar: []*schedule.CalendarSpec{{Minute: "2"}}, + StartTime: timestamppb.New(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)), + EndTime: timestamppb.New(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)), + Jitter: durationpb.New(time.Minute), + TimezoneName: "America/New_York", + TimezoneData: []byte("tzif"), + }, Action: &schedule.ScheduleAction{}, Policies: &schedule.SchedulePolicies{PauseOnFailure: true}, State: &schedule.ScheduleState{Notes: "preserved", Paused: true}} + var updateRequest *workflowservice.UpdateScheduleRequest + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func(ctx context.Context, method string, req any, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + switch request := req.(type) { + case *workflowservice.DescribeScheduleRequest: + response := reply.(*workflowservice.DescribeScheduleResponse) + response.Schedule = proto.Clone(describedSchedule).(*schedule.Schedule) + return nil + case *workflowservice.UpdateScheduleRequest: + updateRequest = proto.Clone(request).(*workflowservice.UpdateScheduleRequest) + return nil + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + for _, tc := range []struct { + name string + arg string + apply func(*schedule.Schedule) + }{ + {name: "start", arg: "--unset-start-time", apply: func(s *schedule.Schedule) { s.Spec.StartTime = nil }}, + {name: "end", arg: "--unset-end-time", apply: func(s *schedule.Schedule) { s.Spec.EndTime = nil }}, + {name: "jitter", arg: "--unset-jitter", apply: func(s *schedule.Schedule) { s.Spec.Jitter = nil }}, + {name: "time zone", arg: "--unset-time-zone", apply: func(s *schedule.Schedule) { s.Spec.TimezoneName, s.Spec.TimezoneData = "", nil }}, + } { + s.T().Run(tc.name, func(t *testing.T) { + updateRequest = nil + res := s.Execute("schedule", "patch", "--address", s.Address(), "--schedule-id", "patch-unset-timing", tc.arg) + assert.NoError(t, res.Err) + if !assert.NotNil(t, updateRequest) { + return + } + expected := proto.Clone(describedSchedule).(*schedule.Schedule) + tc.apply(expected) + assert.True(t, proto.Equal(expected, updateRequest.GetSchedule())) + }) + } + for _, tc := range []struct { + name string + args []string + errorContains string + }{ + {name: "start time", args: []string{"--start-time", "2027-01-01T00:00:00Z", "--unset-start-time"}, errorContains: "--start-time and --unset-start-time are mutually exclusive"}, + {name: "end time", args: []string{"--end-time", "2027-01-01T00:00:00Z", "--unset-end-time"}, errorContains: "--end-time and --unset-end-time are mutually exclusive"}, + {name: "jitter", args: []string{"--jitter", "1s", "--unset-jitter"}, errorContains: "--jitter and --unset-jitter are mutually exclusive"}, + {name: "time zone", args: []string{"--time-zone", "UTC", "--unset-time-zone"}, errorContains: "--time-zone and --unset-time-zone are mutually exclusive"}, + } { + var dials atomic.Int32 + options := s.CommandHarness.Options + options.Args = append([]string{"schedule", "patch", "--schedule-id", "patch-unset-timing"}, tc.args...) + options.AdditionalClientGRPCDialOptions = append(options.AdditionalClientGRPCDialOptions, grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { + dials.Add(1) + return nil, errors.New("unexpected dial") + })) + var commandErr error + options.Fail = func(err error) { commandErr = err } + temporalcli.Execute(context.Background(), options) + assert.ErrorContains(s.T(), commandErr, tc.errorContains, tc.name) + assert.Zero(s.T(), dials.Load(), tc.name) + } +} + +func (s *SharedServerSuite) TestSchedule_PatchSetsOnlyOverlapPolicy() { + var describedSchedule *schedule.Schedule + var updateRequests []*workflowservice.UpdateScheduleRequest + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + switch request := req.(type) { + case *workflowservice.DescribeScheduleRequest: + response := reply.(*workflowservice.DescribeScheduleResponse) + response.Schedule = proto.Clone(describedSchedule).(*schedule.Schedule) + response.ConflictToken = []byte("overlap-token") + return nil + case *workflowservice.UpdateScheduleRequest: + updateRequests = append(updateRequests, proto.Clone(request).(*workflowservice.UpdateScheduleRequest)) + return nil + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + for _, tc := range []struct { + name string + overlap string + schedule *schedule.Schedule + }{ + { + name: "preserves full schedule", + overlap: "Skip", + schedule: &schedule.Schedule{ + Spec: &schedule.ScheduleSpec{CronString: []string{"0 12 * * *"}}, + Policies: &schedule.SchedulePolicies{ + OverlapPolicy: enums.SCHEDULE_OVERLAP_POLICY_BUFFER_ALL, + CatchupWindow: durationpb.New(30 * time.Second), + PauseOnFailure: true, + }, + State: &schedule.ScheduleState{ + Notes: "preserved notes", + Paused: true, + LimitedActions: true, + RemainingActions: 4, + }, + }, + }, + { + name: "creates missing policies", + overlap: "BufferAll", + schedule: &schedule.Schedule{State: &schedule.ScheduleState{Notes: "preserved notes"}}, + }, + { + name: "keeps explicit same value", + overlap: "BufferAll", + schedule: &schedule.Schedule{Policies: &schedule.SchedulePolicies{ + OverlapPolicy: enums.SCHEDULE_OVERLAP_POLICY_BUFFER_ALL, + }}, + }, + } { + s.T().Run(tc.name, func(t *testing.T) { + describedSchedule = tc.schedule + updateRequests = nil + res := s.Execute("schedule", "patch", "--address", s.Address(), "--schedule-id", "schedule-id", "--overlap-policy", tc.overlap) + assert.NoError(t, res.Err) + assert.Len(t, updateRequests, 1) + if len(updateRequests) != 1 { + return + } + expected := proto.Clone(describedSchedule).(*schedule.Schedule) + if expected.Policies == nil { + expected.Policies = &schedule.SchedulePolicies{} + } + expected.Policies.OverlapPolicy, _ = enums.ScheduleOverlapPolicyFromString(tc.overlap) + assert.True(t, proto.Equal(expected, updateRequests[0].GetSchedule())) + }) + } +} + +func (s *SharedServerSuite) TestSchedule_PatchSetsAndUnsetsCatchupWindow() { + var describedSchedule *schedule.Schedule + var updateRequests []*workflowservice.UpdateScheduleRequest + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + switch request := req.(type) { + case *workflowservice.DescribeScheduleRequest: + response := reply.(*workflowservice.DescribeScheduleResponse) + response.Schedule = proto.Clone(describedSchedule).(*schedule.Schedule) + return nil + case *workflowservice.UpdateScheduleRequest: + updateRequests = append(updateRequests, proto.Clone(request).(*workflowservice.UpdateScheduleRequest)) + return nil + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + for _, tc := range []struct { + name string + args []string + schedule *schedule.Schedule + catchupWindow *durationpb.Duration + }{ + { + name: "set preserves full schedule", + args: []string{"--catchup-window", "10s"}, + schedule: &schedule.Schedule{ + Spec: &schedule.ScheduleSpec{CronString: []string{"0 12 * * *"}}, + Policies: &schedule.SchedulePolicies{ + OverlapPolicy: enums.SCHEDULE_OVERLAP_POLICY_BUFFER_ALL, + CatchupWindow: durationpb.New(30 * time.Second), + PauseOnFailure: true, + }, + State: &schedule.ScheduleState{ + Notes: "preserved notes", + Paused: true, + LimitedActions: true, + RemainingActions: 4, + }, + }, + catchupWindow: durationpb.New(10 * time.Second), + }, + { + name: "set creates missing policies", + args: []string{"--catchup-window", "10s"}, + schedule: &schedule.Schedule{State: &schedule.ScheduleState{Notes: "preserved notes"}}, + catchupWindow: durationpb.New(10 * time.Second), + }, + { + name: "unset", + args: []string{"--unset-catchup-window"}, + schedule: &schedule.Schedule{Policies: &schedule.SchedulePolicies{CatchupWindow: durationpb.New(30 * time.Second)}}, + catchupWindow: nil, + }, + } { + s.T().Run(tc.name, func(t *testing.T) { + describedSchedule = tc.schedule + updateRequests = nil + args := append([]string{"schedule", "patch", "--address", s.Address(), "--schedule-id", "schedule-id"}, tc.args...) + res := s.Execute(args...) + assert.NoError(t, res.Err) + assert.Len(t, updateRequests, 1) + if len(updateRequests) == 1 { + expected := proto.Clone(describedSchedule).(*schedule.Schedule) + if expected.Policies == nil { + expected.Policies = &schedule.SchedulePolicies{} + } + expected.Policies.CatchupWindow = tc.catchupWindow + assert.True(t, proto.Equal(expected, updateRequests[0].GetSchedule())) + } + }) + } +} + +func (s *SharedServerSuite) TestSchedule_PatchSetsPauseOnFailureWhenExplicit() { + describedSchedule := &schedule.Schedule{State: &schedule.ScheduleState{Paused: true}} + var updateRequests []*workflowservice.UpdateScheduleRequest + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + switch request := req.(type) { + case *workflowservice.DescribeScheduleRequest: + response := reply.(*workflowservice.DescribeScheduleResponse) + response.Schedule = proto.Clone(describedSchedule).(*schedule.Schedule) + return nil + case *workflowservice.UpdateScheduleRequest: + updateRequests = append(updateRequests, proto.Clone(request).(*workflowservice.UpdateScheduleRequest)) + return nil + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + for _, pauseOnFailure := range []bool{true, false} { + s.T().Run(strconv.FormatBool(pauseOnFailure), func(t *testing.T) { + updateRequests = nil + res := s.Execute("schedule", "patch", "--address", s.Address(), "--schedule-id", "schedule-id", "--pause-on-failure="+strconv.FormatBool(pauseOnFailure)) + assert.NoError(t, res.Err) + assert.Len(t, updateRequests, 1) + if len(updateRequests) == 1 { + assert.Equal(t, pauseOnFailure, updateRequests[0].GetSchedule().GetPolicies().GetPauseOnFailure()) + assert.True(t, updateRequests[0].GetSchedule().GetState().GetPaused()) + } + }) + } +} + +func (s *SharedServerSuite) TestSchedule_PatchChangesOnlyPausedState() { + var describedSchedule *schedule.Schedule + var updateRequests []*workflowservice.UpdateScheduleRequest + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + switch request := req.(type) { + case *workflowservice.DescribeScheduleRequest: + response := reply.(*workflowservice.DescribeScheduleResponse) + response.Schedule = proto.Clone(describedSchedule).(*schedule.Schedule) + return nil + case *workflowservice.UpdateScheduleRequest: + updateRequests = append(updateRequests, proto.Clone(request).(*workflowservice.UpdateScheduleRequest)) + return nil + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + for _, tc := range []struct { + name string + paused bool + schedule *schedule.Schedule + }{ + {name: "true with nil state", paused: true, schedule: &schedule.Schedule{}}, + {name: "false preserves notes", paused: false, schedule: &schedule.Schedule{State: &schedule.ScheduleState{Paused: true, Notes: "preserved notes"}}}, + } { + s.T().Run(tc.name, func(t *testing.T) { + describedSchedule = tc.schedule + updateRequests = nil + res := s.Execute("schedule", "patch", "--address", s.Address(), "--schedule-id", "schedule-id", "--paused="+strconv.FormatBool(tc.paused)) + assert.NoError(t, res.Err) + assert.Len(t, updateRequests, 1) + if len(updateRequests) == 1 { + expected := proto.Clone(tc.schedule).(*schedule.Schedule) + if expected.State == nil { + expected.State = &schedule.ScheduleState{} + } + expected.State.Paused = tc.paused + assert.True(t, proto.Equal(expected, updateRequests[0].GetSchedule())) + } + }) + } +} + +func (s *SharedServerSuite) TestSchedule_PatchSetsRemainingActions() { + var describedSchedule *schedule.Schedule + var updateRequests []*workflowservice.UpdateScheduleRequest + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + switch request := req.(type) { + case *workflowservice.DescribeScheduleRequest: + response := reply.(*workflowservice.DescribeScheduleResponse) + response.Schedule = proto.Clone(describedSchedule).(*schedule.Schedule) + return nil + case *workflowservice.UpdateScheduleRequest: + updateRequests = append(updateRequests, proto.Clone(request).(*workflowservice.UpdateScheduleRequest)) + return nil + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + for _, tc := range []struct { + name string + value string + schedule *schedule.Schedule + limited bool + remaining int64 + }{ + { + name: "positive preserves rich state", + value: "3", + schedule: &schedule.Schedule{ + Spec: &schedule.ScheduleSpec{CronString: []string{"0 12 * * *"}}, + Policies: &schedule.SchedulePolicies{ + OverlapPolicy: enums.SCHEDULE_OVERLAP_POLICY_BUFFER_ALL, + CatchupWindow: durationpb.New(20 * time.Second), + PauseOnFailure: true, + }, + State: &schedule.ScheduleState{ + Notes: "preserved notes", + Paused: true, + }, + }, + limited: true, + remaining: 3, + }, + { + name: "zero preserves rich state", + value: "0", + schedule: &schedule.Schedule{State: &schedule.ScheduleState{ + Notes: "preserved notes", + Paused: true, + LimitedActions: true, + RemainingActions: 9, + }}, + limited: false, + remaining: 0, + }, + { + name: "positive creates missing state", + value: "3", + schedule: &schedule.Schedule{}, + limited: true, + remaining: 3, + }, + } { + s.T().Run(tc.name, func(t *testing.T) { + describedSchedule = tc.schedule + updateRequests = nil + res := s.Execute("schedule", "patch", "--address", s.Address(), "--schedule-id", "schedule-id", "--remaining-actions", tc.value) + assert.NoError(t, res.Err) + assert.Len(t, updateRequests, 1) + if len(updateRequests) == 1 { + expected := proto.Clone(describedSchedule).(*schedule.Schedule) + if expected.State == nil { + expected.State = &schedule.ScheduleState{} + } + expected.State.LimitedActions = tc.limited + expected.State.RemainingActions = tc.remaining + assert.True(t, proto.Equal(expected, updateRequests[0].GetSchedule())) + } + }) + } +} + +func (s *SharedServerSuite) TestSchedule_PatchRetriesExactConflictWithRefreshedState() { + const ( + namespace = "patch-conflict-namespace" + scheduleID = "patch-conflict-schedule" + identity = "patch-conflict-identity" + ) + conflictTokens := [][]byte{ + []byte("stale-conflict-token"), + []byte("refreshed-conflict-token"), + } + describedSchedules := []*schedule.Schedule{ + { + Spec: &schedule.ScheduleSpec{CronString: []string{"0 12 * * *"}}, + Policies: &schedule.SchedulePolicies{ + OverlapPolicy: enums.SCHEDULE_OVERLAP_POLICY_SKIP, + CatchupWindow: durationpb.New(time.Minute), + PauseOnFailure: true, + }, + State: &schedule.ScheduleState{Notes: "stale notes"}, + }, + { + Spec: &schedule.ScheduleSpec{ + CronString: []string{"15 * * * *"}, + TimezoneName: "America/New_York", + }, + Policies: &schedule.SchedulePolicies{ + OverlapPolicy: enums.SCHEDULE_OVERLAP_POLICY_BUFFER_ALL, + CatchupWindow: durationpb.New(2 * time.Minute), + PauseOnFailure: true, + }, + State: &schedule.ScheduleState{ + Notes: "concurrent notes", + Paused: true, + LimitedActions: true, + RemainingActions: 7, + }, + }, + } + var lock sync.Mutex + var describeRequests []*workflowservice.DescribeScheduleRequest + var updateRequests []*workflowservice.UpdateScheduleRequest + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + switch request := req.(type) { + case *workflowservice.DescribeScheduleRequest: + lock.Lock() + describeRequests = append(describeRequests, proto.Clone(request).(*workflowservice.DescribeScheduleRequest)) + describeIndex := len(describeRequests) - 1 + lock.Unlock() + response := reply.(*workflowservice.DescribeScheduleResponse) + response.Schedule = proto.Clone(describedSchedules[describeIndex]).(*schedule.Schedule) + response.ConflictToken = append([]byte(nil), conflictTokens[describeIndex]...) + return nil + case *workflowservice.UpdateScheduleRequest: + lock.Lock() + updateRequests = append(updateRequests, proto.Clone(request).(*workflowservice.UpdateScheduleRequest)) + updateAttempt := len(updateRequests) + lock.Unlock() + if updateAttempt == 1 { + // Additional dial interceptors run inside the SDK error interceptor, so return + // the wire status it converts to *serviceerror.FailedPrecondition for the command. + return status.Error(codes.FailedPrecondition, "mismatched conflict token") + } + return nil + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + res := s.Execute( + "schedule", "patch", + "--address", s.Address(), + "--namespace", namespace, + "--identity", identity, + "--schedule-id", scheduleID, + "--notes", "requested notes", + "--unset-catchup-window", + "--pause-on-failure=false", + "--paused=false", + "--remaining-actions", "0", + ) + s.NoError(res.Err) + s.Equal("Schedule patch submitted\n", res.Stdout.String()) + s.Empty(res.Stderr.String()) + + lock.Lock() + gotDescribeRequests := append([]*workflowservice.DescribeScheduleRequest(nil), describeRequests...) + gotUpdateRequests := append([]*workflowservice.UpdateScheduleRequest(nil), updateRequests...) + lock.Unlock() + s.Len(gotDescribeRequests, 2) + s.Len(gotUpdateRequests, 2) + if len(gotDescribeRequests) != 2 || len(gotUpdateRequests) != 2 { + return + } + + requestIDs := map[string]struct{}{} + for i := range 2 { + s.Equal(namespace, gotDescribeRequests[i].GetNamespace()) + s.Equal(scheduleID, gotDescribeRequests[i].GetScheduleId()) + + updateRequest := gotUpdateRequests[i] + s.Equal(namespace, updateRequest.GetNamespace()) + s.Equal(scheduleID, updateRequest.GetScheduleId()) + s.Equal(conflictTokens[i], updateRequest.GetConflictToken()) + s.Equal(identity, updateRequest.GetIdentity()) + s.NotEmpty(updateRequest.GetRequestId()) + _, exists := requestIDs[updateRequest.GetRequestId()] + s.False(exists) + requestIDs[updateRequest.GetRequestId()] = struct{}{} + + expectedSchedule := proto.Clone(describedSchedules[i]).(*schedule.Schedule) + expectedSchedule.State.Notes = "requested notes" + expectedSchedule.Policies.CatchupWindow = nil + expectedSchedule.Policies.PauseOnFailure = false + expectedSchedule.State.Paused = false + expectedSchedule.State.LimitedActions = false + expectedSchedule.State.RemainingActions = 0 + s.True(proto.Equal(expectedSchedule, updateRequest.GetSchedule())) + } +} + +func (s *SharedServerSuite) TestSchedule_PatchStopsAfterThreeExactConflicts() { + const conflictMessage = "mismatched conflict token" + conflictErrors := make([]error, 3) + for i := range conflictErrors { + conflictStatus, err := status.New(codes.FailedPrecondition, conflictMessage).WithDetails( + wrapperspb.String(fmt.Sprintf("attempt-%d", i+1)), + ) + if err != nil { + s.T().Fatalf("failed to construct conflict status: %v", err) + } + conflictErrors[i] = conflictStatus.Err() + } + + var lock sync.Mutex + describeRequests := 0 + updateRequests := 0 + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + switch req.(type) { + case *workflowservice.DescribeScheduleRequest: + lock.Lock() + describeRequests++ + describeAttempt := describeRequests + lock.Unlock() + response := reply.(*workflowservice.DescribeScheduleResponse) + response.Schedule = &schedule.Schedule{State: &schedule.ScheduleState{Notes: "existing notes"}} + response.ConflictToken = []byte(fmt.Sprintf("conflict-token-%d", describeAttempt)) + return nil + case *workflowservice.UpdateScheduleRequest: + lock.Lock() + updateRequests++ + updateAttempt := updateRequests + lock.Unlock() + return conflictErrors[updateAttempt-1] + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + res := s.Execute( + "schedule", "patch", + "--address", s.Address(), + "--schedule-id", "patch-repeated-conflict-schedule", + "--notes", "requested notes", + ) + s.Error(res.Err) + s.NotContains(res.Stdout.String(), "Schedule patch submitted") + s.NotContains(res.Stderr.String(), "Schedule patch may already have been submitted") + + conflictErr, ok := res.Err.(*serviceerror.FailedPrecondition) + if !assert.True(s.T(), ok) { + return + } + s.Equal(conflictMessage, conflictErr.Message) + details := conflictErr.Status().Details() + if !assert.Len(s.T(), details, 1) { + return + } + marker, ok := details[0].(*wrapperspb.StringValue) + if !assert.True(s.T(), ok) { + return + } + s.Equal("attempt-3", marker.GetValue()) + + lock.Lock() + gotDescribeRequests := describeRequests + gotUpdateRequests := updateRequests + lock.Unlock() + s.Equal(3, gotDescribeRequests) + s.Equal(3, gotUpdateRequests) +} + +func (s *SharedServerSuite) TestSchedule_PatchDoesNotRetryOtherTypedFailedPreconditions() { + var lock sync.Mutex + describeRequests := 0 + updateRequests := 0 + updateError := error(nil) + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + switch req.(type) { + case *workflowservice.DescribeScheduleRequest: + lock.Lock() + describeRequests++ + lock.Unlock() + response := reply.(*workflowservice.DescribeScheduleResponse) + response.Schedule = &schedule.Schedule{State: &schedule.ScheduleState{Notes: "existing notes"}} + response.ConflictToken = []byte("conflict-token") + return nil + case *workflowservice.UpdateScheduleRequest: + lock.Lock() + updateRequests++ + err := updateError + lock.Unlock() + return err + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + for _, tc := range []struct { + name string + message string + }{ + {name: "near-miss message", message: "mismatched conflict token "}, + {name: "unrelated failure", message: "schedule is paused"}, + } { + s.T().Run(tc.name, func(t *testing.T) { + lock.Lock() + describeRequests = 0 + updateRequests = 0 + // The inner status is converted by the SDK to the concrete service error + // whose message the command must compare exactly. + updateError = status.Error(codes.FailedPrecondition, tc.message) + lock.Unlock() + + res := s.Execute( + "schedule", "patch", + "--address", s.Address(), + "--schedule-id", "patch-other-precondition-schedule", + "--notes", "requested notes", + ) + assert.Error(t, res.Err) + assert.NotContains(t, res.Stdout.String(), "Schedule patch submitted") + assert.NotContains(t, res.Stderr.String(), "Schedule patch may already have been submitted") + conflictErr, ok := res.Err.(*serviceerror.FailedPrecondition) + if assert.True(t, ok) { + assert.Equal(t, tc.message, conflictErr.Message) + } + + lock.Lock() + gotDescribeRequests := describeRequests + gotUpdateRequests := updateRequests + lock.Unlock() + assert.Equal(t, 1, gotDescribeRequests) + assert.Equal(t, 1, gotUpdateRequests) + }) + } +} + +func (s *SharedServerSuite) TestSchedule_PatchDoesNotRetryBroadOrAmbiguousErrors() { + var lock sync.Mutex + describeRequests := 0 + updateRequests := 0 + updateError := error(nil) + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + // WithUnaryInterceptor is prepended outside the SDK error interceptor. This + // lets the broad status case reach the command without typed conversion. + grpc.WithUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + switch req.(type) { + case *workflowservice.DescribeScheduleRequest: + lock.Lock() + describeRequests++ + lock.Unlock() + response := reply.(*workflowservice.DescribeScheduleResponse) + response.Schedule = &schedule.Schedule{State: &schedule.ScheduleState{Notes: "existing notes"}} + response.ConflictToken = []byte("conflict-token") + return nil + case *workflowservice.UpdateScheduleRequest: + lock.Lock() + updateRequests++ + err := updateError + lock.Unlock() + return err + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + for _, tc := range []struct { + name string + err error + }{ + { + name: "broad status with exact conflict text", + err: status.Error(codes.FailedPrecondition, "mismatched conflict token"), + }, + {name: "deadline", err: context.DeadlineExceeded}, + {name: "unavailable", err: status.Error(codes.Unavailable, "service unavailable")}, + {name: "transport", err: io.ErrUnexpectedEOF}, + {name: "ordinary", err: errors.New("ordinary update failure")}, + } { + s.T().Run(tc.name, func(t *testing.T) { + lock.Lock() + describeRequests = 0 + updateRequests = 0 + updateError = tc.err + lock.Unlock() + + res := s.Execute( + "schedule", "patch", + "--address", s.Address(), + "--schedule-id", "patch-ambiguous-error-schedule", + "--notes", "requested notes", + ) + assert.ErrorIs(t, res.Err, tc.err) + assert.NotContains(t, res.Stdout.String(), "Schedule patch submitted") + assert.NotContains(t, res.Stderr.String(), "Schedule patch may already have been submitted") + + lock.Lock() + gotDescribeRequests := describeRequests + gotUpdateRequests := updateRequests + lock.Unlock() + assert.Equal(t, 1, gotDescribeRequests) + assert.Equal(t, 1, gotUpdateRequests) + }) + } +} + +func (s *SharedServerSuite) TestSchedule_PatchCommandDeadlineStopsDuringRefreshedDescribe() { + var lock sync.Mutex + describeRequests := 0 + updateRequests := 0 + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + switch req.(type) { + case *workflowservice.DescribeScheduleRequest: + lock.Lock() + describeRequests++ + describeAttempt := describeRequests + lock.Unlock() + if describeAttempt == 2 { + <-ctx.Done() + return status.FromContextError(ctx.Err()).Err() + } + response := reply.(*workflowservice.DescribeScheduleResponse) + response.Schedule = &schedule.Schedule{State: &schedule.ScheduleState{Notes: "existing notes"}} + response.ConflictToken = []byte("stale-conflict-token") + return nil + case *workflowservice.UpdateScheduleRequest: + lock.Lock() + updateRequests++ + lock.Unlock() + return status.Error(codes.FailedPrecondition, "mismatched conflict token") + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + res := s.Execute( + "schedule", "patch", + "--address", s.Address(), + "--command-timeout", "500ms", + "--schedule-id", "patch-deadline-schedule", + "--notes", "requested notes", + ) + s.Error(res.Err) + deadlineErr, ok := res.Err.(*serviceerror.DeadlineExceeded) + if assert.True(s.T(), ok) { + s.Equal(context.DeadlineExceeded.Error(), deadlineErr.Message) + } + s.NotContains(res.Stdout.String(), "Schedule patch submitted") + s.NotContains(res.Stderr.String(), "Schedule patch may already have been submitted") + + lock.Lock() + gotDescribeRequests := describeRequests + gotUpdateRequests := updateRequests + lock.Unlock() + s.Equal(2, gotDescribeRequests) + s.Equal(1, gotUpdateRequests) +} + +func (s *SharedServerSuite) TestSchedule_PatchSubmitsOneUpdateThatClearsOnlyNotes() { + const ( + namespace = "patch-unset-notes-namespace" + scheduleID = "patch-unset-notes-schedule" + identity = "patch-unset-notes-identity" + ) + conflictToken := []byte("patch-unset-notes-conflict-token") + describedSchedule := &schedule.Schedule{ + Spec: &schedule.ScheduleSpec{ + CronString: []string{"0 12 * * *"}, + TimezoneName: "America/New_York", + TimezoneData: []byte{1, 2, 3}, + }, + Policies: &schedule.SchedulePolicies{ + PauseOnFailure: true, + KeepOriginalWorkflowId: true, + }, + State: &schedule.ScheduleState{ + Notes: "notes to clear", + Paused: true, + LimitedActions: true, + RemainingActions: 3, + }, + } + var lock sync.Mutex + var describeRequests []*workflowservice.DescribeScheduleRequest + var updateRequests []*workflowservice.UpdateScheduleRequest + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + switch request := req.(type) { + case *workflowservice.DescribeScheduleRequest: + lock.Lock() + describeRequests = append(describeRequests, proto.Clone(request).(*workflowservice.DescribeScheduleRequest)) + lock.Unlock() + response := reply.(*workflowservice.DescribeScheduleResponse) + response.Schedule = proto.Clone(describedSchedule).(*schedule.Schedule) + response.ConflictToken = append([]byte(nil), conflictToken...) + return nil + case *workflowservice.UpdateScheduleRequest: + lock.Lock() + updateRequests = append(updateRequests, proto.Clone(request).(*workflowservice.UpdateScheduleRequest)) + lock.Unlock() + return nil + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + res := s.Execute( + "schedule", "patch", + "--address", s.Address(), + "--namespace", namespace, + "--identity", identity, + "--schedule-id", scheduleID, + "--unset-notes", + ) + s.NoError(res.Err) + + lock.Lock() + gotDescribeRequests := append([]*workflowservice.DescribeScheduleRequest(nil), describeRequests...) + gotUpdateRequests := append([]*workflowservice.UpdateScheduleRequest(nil), updateRequests...) + lock.Unlock() + s.Len(gotDescribeRequests, 1) + s.Len(gotUpdateRequests, 1) + if len(gotDescribeRequests) != 1 || len(gotUpdateRequests) != 1 { + return + } + + s.Equal(namespace, gotDescribeRequests[0].GetNamespace()) + s.Equal(scheduleID, gotDescribeRequests[0].GetScheduleId()) + + updateRequest := gotUpdateRequests[0] + s.Equal(namespace, updateRequest.GetNamespace()) + s.Equal(scheduleID, updateRequest.GetScheduleId()) + s.Equal(conflictToken, updateRequest.GetConflictToken()) + s.Equal(identity, updateRequest.GetIdentity()) + s.NotEmpty(updateRequest.GetRequestId()) + + expectedSchedule := proto.Clone(describedSchedule).(*schedule.Schedule) + expectedSchedule.State.Notes = "" + s.True(proto.Equal(expectedSchedule, updateRequest.GetSchedule())) +} + +func (s *SharedServerSuite) TestSchedule_PatchDescribeErrorsStopBeforeUpdate() { + const injectedDescribeError = "injected Describe error" + + var lock sync.Mutex + var injectDescribeError bool + var describeRequests []*workflowservice.DescribeScheduleRequest + var updateRequests []*workflowservice.UpdateScheduleRequest + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + switch request := req.(type) { + case *workflowservice.DescribeScheduleRequest: + lock.Lock() + describeRequests = append(describeRequests, proto.Clone(request).(*workflowservice.DescribeScheduleRequest)) + inject := injectDescribeError + lock.Unlock() + if inject { + return errors.New(injectedDescribeError) + } + return invoker(ctx, method, req, reply, cc, opts...) + case *workflowservice.UpdateScheduleRequest: + lock.Lock() + updateRequests = append(updateRequests, proto.Clone(request).(*workflowservice.UpdateScheduleRequest)) + lock.Unlock() + return invoker(ctx, method, req, reply, cc, opts...) + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + for _, tc := range []struct { + name string + scheduleID string + inject bool + errorContains string + }{ + { + name: "injected Describe error", + scheduleID: "patch-injected-describe-error", + inject: true, + errorContains: injectedDescribeError, + }, + { + name: "nonexistent Schedule", + scheduleID: "patch-nonexistent-schedule", + }, + } { + s.T().Run(tc.name, func(t *testing.T) { + lock.Lock() + injectDescribeError = tc.inject + describeRequests = nil + updateRequests = nil + lock.Unlock() + + res := s.Execute( + "schedule", "patch", + "--address", s.Address(), + "--schedule-id", tc.scheduleID, + "--notes", "updated notes", + ) + assert.Error(t, res.Err) + if tc.errorContains != "" { + assert.ErrorContains(t, res.Err, tc.errorContains) + } + + lock.Lock() + gotDescribeRequests := append([]*workflowservice.DescribeScheduleRequest(nil), describeRequests...) + gotUpdateRequests := append([]*workflowservice.UpdateScheduleRequest(nil), updateRequests...) + lock.Unlock() + assert.Len(t, gotDescribeRequests, 1) + assert.Len(t, gotUpdateRequests, 0) + }) + } +} + +func (s *SharedServerSuite) TestSchedule_PatchUpdateFailureDoesNotClaimSubmission() { + const ( + namespace = "patch-update-error-namespace" + scheduleID = "patch-update-error-schedule" + ) + wantErr := errors.New("injected UpdateSchedule error") + conflictToken := []byte("patch-update-error-conflict-token") + describedSchedule := &schedule.Schedule{ + State: &schedule.ScheduleState{Notes: "existing notes"}, + } + + var lock sync.Mutex + describeRequests := 0 + updateRequests := 0 + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + switch req.(type) { + case *workflowservice.DescribeScheduleRequest: + lock.Lock() + describeRequests++ + lock.Unlock() + response := reply.(*workflowservice.DescribeScheduleResponse) + response.Schedule = proto.Clone(describedSchedule).(*schedule.Schedule) + response.ConflictToken = append([]byte(nil), conflictToken...) + return nil + case *workflowservice.UpdateScheduleRequest: + lock.Lock() + updateRequests++ + lock.Unlock() + return wantErr + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + res := s.Execute( + "schedule", "patch", + "--address", s.Address(), + "--namespace", namespace, + "--schedule-id", scheduleID, + "--notes", "updated notes", + ) + assert.Error(s.T(), res.Err) + assert.ErrorContains(s.T(), res.Err, wantErr.Error()) + assert.NotContains(s.T(), res.Stdout.String(), "Schedule patch submitted\n") + assert.NotContains(s.T(), res.Stderr.String(), "Schedule patch may already have been submitted") + + lock.Lock() + gotDescribeRequests := describeRequests + gotUpdateRequests := updateRequests + lock.Unlock() + assert.Equal(s.T(), 1, gotDescribeRequests) + assert.Equal(s.T(), 1, gotUpdateRequests) +} + +func (s *SharedServerSuite) TestSchedule_PatchSuccessfulOutputModes() { + const ( + namespace = "patch-output-namespace" + scheduleID = "patch-output-schedule" + ) + conflictToken := []byte("patch-output-conflict-token") + describedSchedule := &schedule.Schedule{ + State: &schedule.ScheduleState{Notes: "existing notes"}, + } + + var lock sync.Mutex + describeRequests := 0 + updateRequests := 0 + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + switch req.(type) { + case *workflowservice.DescribeScheduleRequest: + lock.Lock() + describeRequests++ + lock.Unlock() + response := reply.(*workflowservice.DescribeScheduleResponse) + response.Schedule = proto.Clone(describedSchedule).(*schedule.Schedule) + response.ConflictToken = append([]byte(nil), conflictToken...) + return nil + case *workflowservice.UpdateScheduleRequest: + lock.Lock() + updateRequests++ + lock.Unlock() + return nil + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + for _, tc := range []struct { + name string + outputArgs []string + wantStdout string + }{ + {name: "text", wantStdout: "Schedule patch submitted\n"}, + {name: "json", outputArgs: []string{"--output", "json"}}, + {name: "jsonl", outputArgs: []string{"--output", "jsonl"}}, + {name: "none", outputArgs: []string{"--output", "none"}}, + } { + s.T().Run(tc.name, func(t *testing.T) { + lock.Lock() + describeRequests = 0 + updateRequests = 0 + lock.Unlock() + + args := append([]string{ + "schedule", "patch", + "--address", s.Address(), + "--namespace", namespace, + "--schedule-id", scheduleID, + "--notes", "updated notes", + }, tc.outputArgs...) + res := s.Execute(args...) + assert.NoError(t, res.Err) + assert.Equal(t, tc.wantStdout, res.Stdout.String()) + assert.Empty(t, res.Stderr.String()) + + lock.Lock() + gotDescribeRequests := describeRequests + gotUpdateRequests := updateRequests + lock.Unlock() + assert.Equal(t, 1, gotDescribeRequests) + assert.Equal(t, 1, gotUpdateRequests) + }) + } +} + +func (s *SharedServerSuite) TestSchedule_PatchWarnsWhenAcknowledgementWriteFails() { + const ( + namespace = "patch-write-error-namespace" + scheduleID = "patch-write-error-schedule" + ) + conflictToken := []byte("patch-write-error-conflict-token") + describedSchedule := &schedule.Schedule{ + State: &schedule.ScheduleState{Notes: "existing notes"}, + } + + var lock sync.Mutex + describeRequests := 0 + updateRequests := 0 + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + switch req.(type) { + case *workflowservice.DescribeScheduleRequest: + lock.Lock() + describeRequests++ + lock.Unlock() + response := reply.(*workflowservice.DescribeScheduleResponse) + response.Schedule = proto.Clone(describedSchedule).(*schedule.Schedule) + response.ConflictToken = append([]byte(nil), conflictToken...) + return nil + case *workflowservice.UpdateScheduleRequest: + lock.Lock() + updateRequests++ + lock.Unlock() + return nil + default: + return invoker(ctx, method, req, reply, cc, opts...) + } + }), + ) + + for _, testCase := range []struct { + name string + wantErr error + }{ + {name: "ordinary write error", wantErr: errors.New("stdout write failed")}, + {name: "broken pipe", wantErr: syscall.EPIPE}, + } { + s.T().Run(testCase.name, func(t *testing.T) { + lock.Lock() + describeRequests = 0 + updateRequests = 0 + lock.Unlock() + + stdout := &failAfterWriter{remaining: 1, err: testCase.wantErr} + err, stderr := s.executeWithStdout( + stdout, + "schedule", "patch", + "--address", s.Address(), + "--namespace", namespace, + "--schedule-id", scheduleID, + "--notes", "updated notes", + ) + assert.Equal(t, testCase.wantErr, err) + assert.ErrorIs(t, err, testCase.wantErr) + assert.NotContains(t, stdout.buf.String(), "Schedule patch submitted\n") + assert.Equal(t, "Schedule patch may already have been submitted\nError: "+testCase.wantErr.Error()+"\n", stderr) + + lock.Lock() + gotDescribeRequests := describeRequests + gotUpdateRequests := updateRequests + lock.Unlock() + assert.Equal(t, 1, gotDescribeRequests) + assert.Equal(t, 1, gotUpdateRequests) + }) + } +} diff --git a/internal/temporalcli/commands.schedule_test.go b/internal/temporalcli/commands.schedule_test.go index d3bf978d9..f5991da85 100644 --- a/internal/temporalcli/commands.schedule_test.go +++ b/internal/temporalcli/commands.schedule_test.go @@ -5,20 +5,149 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "math/rand" "regexp" "strings" + "sync/atomic" + "testing" "time" "github.com/stretchr/testify/assert" "github.com/temporalio/cli/internal/temporalcli" "go.temporal.io/api/enums/v1" "go.temporal.io/api/operatorservice/v1" + "go.temporal.io/api/schedule/v1" + "go.temporal.io/api/workflowservice/v1" "go.temporal.io/sdk/workflow" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) +type failAfterWriter struct { + buf bytes.Buffer + remaining int + err error +} + +type failListFinalizationWriter struct { + buf bytes.Buffer + err error +} + +type shortWriteBuffer struct { + buf bytes.Buffer +} + +type failOnJSONItemWriter struct { + buf bytes.Buffer + itemWrites int + failItem int + err error +} + +type failItemAndFinalizationWriter struct { + itemErr error + finalizationErr error + finalizationCalls int +} + +func (w *failItemAndFinalizationWriter) Write(p []byte) (int, error) { + switch { + case bytes.Equal(p, []byte("\n]\n")): + w.finalizationCalls++ + if w.finalizationErr == nil { + return len(p), nil + } + return 0, w.finalizationErr + case len(p) > 0 && p[0] == '{': + return 0, w.itemErr + default: + return len(p), nil + } +} + +func (w *failOnJSONItemWriter) Write(p []byte) (int, error) { + if len(p) > 0 && p[0] == '{' { + w.itemWrites++ + if w.itemWrites == w.failItem { + return 0, w.err + } + } + return w.buf.Write(p) +} + +func (w *shortWriteBuffer) Write(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + return w.buf.Write(p[:len(p)-1]) +} + +func (w *failListFinalizationWriter) Write(p []byte) (int, error) { + if bytes.Equal(p, []byte("\n]\n")) { + return 0, w.err + } + return w.buf.Write(p) +} + +func (w *failAfterWriter) Write(p []byte) (int, error) { + if w.remaining <= 0 { + return 0, w.err + } + if len(p) > w.remaining { + p = p[:w.remaining] + } + n, _ := w.buf.Write(p) + w.remaining -= n + if w.remaining == 0 { + return n, w.err + } + return n, nil +} + +func (s *SharedServerSuite) executeWithStdout(stdout io.Writer, args ...string) (error, string) { + options := s.CommandHarness.Options + var stderr bytes.Buffer + options.Stdin = &s.Stdin + options.Stdout = stdout + options.Stderr = &stderr + options.Args = args + options.DeprecatedEnvConfig.DisableEnvConfig = true + options.DeprecatedEnvConfig.EnvConfigName = "default" + var commandErr error + options.Fail = func(err error) { + commandErr = err + fmt.Fprintf(&stderr, "Error: %v\n", err) + } + temporalcli.Execute(s.Context, options) + return commandErr, stderr.String() +} + +func (s *SharedServerSuite) stubScheduleListResponse(entries ...*schedule.ScheduleListEntry) { + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + if _, ok := req.(*workflowservice.ListSchedulesRequest); !ok { + return invoker(ctx, method, req, reply, cc, opts...) + } + reply.(*workflowservice.ListSchedulesResponse).Schedules = entries + return nil + }), + ) +} + func (s *SharedServerSuite) createSchedule(args ...string) (schedId, schedWfId string, res *CommandResult) { schedId = fmt.Sprintf("sched-%x", rand.Uint32()) schedWfId = fmt.Sprintf("my-wf-id-%x", rand.Uint32()) @@ -54,11 +183,107 @@ func (s *SharedServerSuite) createSchedule(args ...string) (schedId, schedWfId s return } +func (s *SharedServerSuite) updateSchedule(schedID, schedWorkflowID string, args ...string) *CommandResult { + return s.Execute(append([]string{ + "schedule", "update", + "--address", s.Address(), + "--schedule-id", schedID, + "--task-queue", s.Worker().Options.TaskQueue, + "--type", "DevWorkflow", + "--workflow-id", schedWorkflowID, + }, args...)...) +} + func (s *SharedServerSuite) TestSchedule_Create() { _, _, res := s.createSchedule("--interval", "10d") s.NoError(res.Err) } +func (s *SharedServerSuite) TestSchedule_CreateRejectsHeadersBeforeMutation() { + var createRequests atomic.Int32 + s.DevServer.SetServerInterceptor(func( + ctx context.Context, + req any, + info *grpc.UnaryServerInfo, + handler grpc.UnaryHandler, + ) (any, error) { + if _, ok := req.(*workflowservice.CreateScheduleRequest); ok { + createRequests.Add(1) + } + return handler(ctx, req) + }) + + _, _, res := s.createSchedule("--interval", "10d", "--headers", "example=123") + s.Error(res.Err) + s.ErrorContains(res.Err, "headers are not supported for schedule actions") + s.Equal(int32(0), createRequests.Load()) +} + +func (s *SharedServerSuite) TestSchedule_CreateForwardsPriorityAndFairnessPolicyValues() { + var createRequest *workflowservice.CreateScheduleRequest + s.DevServer.SetServerInterceptor(func( + ctx context.Context, + req any, + info *grpc.UnaryServerInfo, + handler grpc.UnaryHandler, + ) (any, error) { + if request, ok := req.(*workflowservice.CreateScheduleRequest); ok { + createRequest = request + return &workflowservice.CreateScheduleResponse{}, nil + } + return handler(ctx, req) + }) + + _, _, res := s.createSchedule( + "--interval", "10d", + "--priority-key", "-1", + "--fairness-key", strings.Repeat("a", 65), + "--fairness-weight", "-1", + ) + s.NoError(res.Err) + if createRequest == nil { + s.Fail("CreateSchedule request was not captured") + return + } + + priority := createRequest.GetSchedule().GetAction().GetStartWorkflow().GetPriority() + if priority == nil { + s.Fail("CreateSchedule request did not include priority") + return + } + s.Equal(int32(-1), priority.GetPriorityKey()) + s.Equal(strings.Repeat("a", 65), priority.GetFairnessKey()) + s.Equal(float32(-1), priority.GetFairnessWeight()) +} + +func (s *SharedServerSuite) TestSchedule_CreateAcceptsEmptyFairnessKeyAndZeroWeight() { + var createRequest *workflowservice.CreateScheduleRequest + s.DevServer.SetServerInterceptor(func( + ctx context.Context, + req any, + info *grpc.UnaryServerInfo, + handler grpc.UnaryHandler, + ) (any, error) { + if request, ok := req.(*workflowservice.CreateScheduleRequest); ok { + createRequest = request + } + return handler(ctx, req) + }) + + _, _, res := s.createSchedule( + "--interval", "10d", + "--fairness-key=", + "--fairness-weight", "0", + ) + s.NoError(res.Err) + if createRequest == nil { + s.Fail("CreateSchedule request was not captured") + return + } + + s.Nil(createRequest.GetSchedule().GetAction().GetStartWorkflow().GetPriority()) +} + func (s *SharedServerSuite) TestSchedule_Delete() { schedId, _, res := s.createSchedule("--interval", "10d") s.NoError(res.Err) @@ -136,6 +361,119 @@ func (s *SharedServerSuite) TestSchedule_Describe() { s.Equal(schedWfId, j.Schedule.Action.StartWorkflow.Id) } +func (s *SharedServerSuite) TestSchedule_DescribeReturnsWriteFailure() { + schedID, _, res := s.createSchedule("--interval", "10d") + s.NoError(res.Err) + + for _, testCase := range []struct { + name string + outputArgs []string + }{ + {name: "text"}, + {name: "JSON", outputArgs: []string{"--output", "json"}}, + {name: "JSONL", outputArgs: []string{"--output", "jsonl"}}, + } { + s.T().Run(testCase.name, func(t *testing.T) { + wantErr := errors.New("stdout write failed") + stdout := &failAfterWriter{remaining: 8, err: wantErr} + args := []string{ + "schedule", "describe", + "--address", s.Address(), + "--schedule-id", schedID, + } + err, stderr := s.executeWithStdout(stdout, append(args, testCase.outputArgs...)...) + + assert.ErrorIs(t, err, wantErr) + assert.Contains(t, stderr, wantErr.Error()) + assert.NotEmpty(t, stdout.buf.String()) + }) + } +} + +func (s *SharedServerSuite) TestSchedule_DescribeStructuredReturnsSerializationFailure() { + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + if _, ok := req.(*workflowservice.DescribeScheduleRequest); ok { + resp := reply.(*workflowservice.DescribeScheduleResponse) + resp.Schedule = &schedule.Schedule{ + State: &schedule.ScheduleState{Notes: string([]byte{0xff})}, + } + return nil + } + return invoker(ctx, method, req, reply, cc, opts...) + }), + ) + + for _, output := range []string{"json", "jsonl"} { + s.T().Run(output, func(t *testing.T) { + var stdout bytes.Buffer + err, stderr := s.executeWithStdout( + &stdout, + "schedule", "describe", + "--address", s.Address(), + "--schedule-id", "serialization-failure", + "--output", output, + ) + + assert.Error(t, err) + assert.Contains(t, stderr, "invalid UTF-8") + }) + } +} + +func (s *SharedServerSuite) TestSchedule_DescribeTextReturnsSerializationFailure() { + schedID, _, res := s.createSchedule("--calendar", `{"minute":"0","comment":"valid"}`) + s.NoError(res.Err) + + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + if err := invoker(ctx, method, req, reply, cc, opts...); err != nil { + return err + } + if _, ok := req.(*workflowservice.DescribeScheduleRequest); !ok { + return nil + } + resp := reply.(*workflowservice.DescribeScheduleResponse) + calendars := resp.GetSchedule().GetSpec().GetStructuredCalendar() + if len(calendars) == 0 { + return errors.New("valid Describe response has no structured calendar") + } + calendars[0].Comment = string([]byte{0xff}) + return nil + }), + ) + + var stdout bytes.Buffer + err, stderr := s.executeWithStdout( + &stdout, + "schedule", "describe", + "--address", s.Address(), + "--schedule-id", schedID, + ) + + s.Error(err) + s.Contains(stderr, "invalid UTF-8") + s.Empty(stdout.String()) +} + func (s *SharedServerSuite) TestSchedule_CreateDescribeCalendar() { schedId, _, res := s.createSchedule("--calendar", `{"hour":"2,4","dayOfWeek":"thu,fri"}`) s.NoError(res.Err) @@ -365,6 +703,264 @@ func (s *SharedServerSuite) TestSchedule_List() { s.Error(res.Err) } +func (s *SharedServerSuite) TestSchedule_ListReturnsOpeningFailure() { + wantErr := errors.New("stdout opening failed") + stdout := &failAfterWriter{err: wantErr} + + err, stderr := s.executeWithStdout( + stdout, + "schedule", "list", + "--address", s.Address(), + "--output", "json", + ) + + assert.ErrorIs(s.T(), err, wantErr) + assert.Contains(s.T(), stderr, wantErr.Error()) +} + +func (s *SharedServerSuite) TestSchedule_ListReturnsFirstItemWriteFailure() { + s.stubScheduleListResponse(&schedule.ScheduleListEntry{ScheduleId: "first-item"}) + wantErr := errors.New("stdout first item failed") + stdout := &failAfterWriter{err: wantErr} + + err, stderr := s.executeWithStdout( + stdout, + "schedule", "list", + "--address", s.Address(), + "--output", "jsonl", + ) + + assert.ErrorIs(s.T(), err, wantErr) + assert.Contains(s.T(), stderr, wantErr.Error()) +} + +func (s *SharedServerSuite) TestSchedule_ListFinalizesAfterItemFailureAndPreservesBothErrors() { + s.stubScheduleListResponse(&schedule.ScheduleListEntry{ScheduleId: "first-item"}) + itemErr := errors.New("stdout item failed") + finalizationErr := errors.New("stdout finalization also failed") + stdout := &failItemAndFinalizationWriter{ + itemErr: itemErr, + finalizationErr: finalizationErr, + } + + err, stderr := s.executeWithStdout( + stdout, + "schedule", "list", + "--address", s.Address(), + "--output", "json", + ) + + assert.ErrorIs(s.T(), err, itemErr) + assert.ErrorIs(s.T(), err, finalizationErr) + assert.Contains(s.T(), stderr, itemErr.Error()) + assert.Contains(s.T(), stderr, finalizationErr.Error()) + assert.Equal(s.T(), 1, stdout.finalizationCalls) +} + +func (s *SharedServerSuite) TestSchedule_ListFinalizesAfterRPCFailureAndPreservesPrimaryError() { + rpcErr := errors.New("list RPC failed") + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = append( + s.CommandHarness.Options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + if _, ok := req.(*workflowservice.ListSchedulesRequest); ok { + return rpcErr + } + return invoker(ctx, method, req, reply, cc, opts...) + }), + ) + stdout := &failItemAndFinalizationWriter{} + + err, stderr := s.executeWithStdout( + stdout, + "schedule", "list", + "--address", s.Address(), + "--output", "json", + ) + + assert.ErrorContains(s.T(), err, rpcErr.Error()) + assert.Contains(s.T(), stderr, rpcErr.Error()) + assert.Equal(s.T(), 1, stdout.finalizationCalls) +} + +func (s *SharedServerSuite) TestSchedule_ListTextFinalizesAfterIteratorFailure() { + const iteratorErrMessage = "list iterator failed" + iteratorErr := status.Error(codes.InvalidArgument, iteratorErrMessage) + options := s.CommandHarness.Options + options.AdditionalClientGRPCDialOptions = append( + options.AdditionalClientGRPCDialOptions, + grpc.WithChainUnaryInterceptor(func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + if _, ok := req.(*workflowservice.ListSchedulesRequest); ok { + return iteratorErr + } + return invoker(ctx, method, req, reply, cc, opts...) + }), + ) + var stdout, stderr bytes.Buffer + options.Stdin = &s.Stdin + options.Stdout = &stdout + options.Stderr = &stderr + options.Args = []string{ + "schedule", "list", + "--address", s.Address(), + } + options.DeprecatedEnvConfig.DisableEnvConfig = true + options.DeprecatedEnvConfig.EnvConfigName = "default" + var commandErr error + options.Fail = func(err error) { + commandErr = err + } + cctx, cancel, err := temporalcli.NewCommandContext(s.Context, options) + s.NoError(err) + defer cancel() + cmd := temporalcli.NewTemporalCommand(cctx) + cmd.Command.SetArgs(cctx.Options.Args) + cmd.Command.SetOut(cctx.Options.Stdout) + cmd.Command.SetErr(cctx.Options.Stderr) + + err = cmd.Command.ExecuteContext(cctx) + + s.NoError(err) + s.ErrorContains(commandErr, iteratorErrMessage) + var restartErr error + s.NotPanics(func() { + restartErr = cctx.Printer.StartListErr() + }) + s.NoError(restartErr) + s.NoError(cctx.Printer.EndListErr()) +} + +func (s *SharedServerSuite) TestSchedule_ListReturnsMiddleItemWriteFailure() { + s.stubScheduleListResponse( + &schedule.ScheduleListEntry{ScheduleId: "first-item"}, + &schedule.ScheduleListEntry{ScheduleId: "middle-item"}, + ) + wantErr := errors.New("stdout middle item failed") + stdout := &failOnJSONItemWriter{failItem: 2, err: wantErr} + + err, stderr := s.executeWithStdout( + stdout, + "schedule", "list", + "--address", s.Address(), + "--output", "jsonl", + ) + + assert.ErrorIs(s.T(), err, wantErr) + assert.Contains(s.T(), stderr, wantErr.Error()) + assert.Equal(s.T(), 2, stdout.itemWrites) + assert.NotEmpty(s.T(), stdout.buf.String()) +} + +func (s *SharedServerSuite) TestSchedule_ListReturnsEveryItemSerializationFailure() { + dialOptions := s.CommandHarness.Options.AdditionalClientGRPCDialOptions + + for _, testCase := range []struct { + name string + schedules []*schedule.ScheduleListEntry + wantPrior bool + }{ + { + name: "first item", + schedules: []*schedule.ScheduleListEntry{ + {ScheduleId: string([]byte{0xff})}, + }, + }, + { + name: "middle item", + schedules: []*schedule.ScheduleListEntry{ + {ScheduleId: "valid-prior-item"}, + {ScheduleId: string([]byte{0xff})}, + {ScheduleId: "unreached-item"}, + }, + wantPrior: true, + }, + } { + s.T().Run(testCase.name, func(t *testing.T) { + s.CommandHarness.Options.AdditionalClientGRPCDialOptions = dialOptions + s.stubScheduleListResponse(testCase.schedules...) + var stdout bytes.Buffer + + err, stderr := s.executeWithStdout( + &stdout, + "schedule", "list", + "--address", s.Address(), + "--output", "jsonl", + ) + + assert.Error(t, err) + assert.Contains(t, stderr, "invalid UTF-8") + assert.Equal(t, testCase.wantPrior, strings.Contains(stdout.String(), "valid-prior-item")) + assert.NotContains(t, stdout.String(), "unreached-item") + }) + } +} + +func (s *SharedServerSuite) TestSchedule_ListTextReturnsItemShortWrite() { + s.stubScheduleListResponse(&schedule.ScheduleListEntry{ + ScheduleId: "first-item", + Info: &schedule.ScheduleListInfo{ + Spec: &schedule.ScheduleSpec{}, + }, + }) + stdout := &shortWriteBuffer{} + + err, stderr := s.executeWithStdout( + stdout, + "schedule", "list", + "--address", s.Address(), + ) + + assert.ErrorIs(s.T(), err, io.ErrShortWrite) + assert.Contains(s.T(), stderr, io.ErrShortWrite.Error()) +} + +func (s *SharedServerSuite) TestSchedule_ListReturnsFinalizationFailure() { + _, _, res := s.createSchedule("--interval", "10d") + s.NoError(res.Err) + wantErr := errors.New("stdout finalization failed") + stdout := &failListFinalizationWriter{err: wantErr} + + err, stderr := s.executeWithStdout( + stdout, + "schedule", "list", + "--address", s.Address(), + "--output", "json", + ) + + assert.ErrorIs(s.T(), err, wantErr) + assert.Contains(s.T(), stderr, wantErr.Error()) + assert.NotEmpty(s.T(), stdout.buf.String()) +} + +func (s *SharedServerSuite) TestSchedule_ListNoneOutputRemainsEmpty() { + _, _, res := s.createSchedule("--interval", "10d") + s.NoError(res.Err) + + res = s.Execute( + "schedule", "list", + "--address", s.Address(), + "--output", "none", + ) + + s.NoError(res.Err) + s.Empty(res.Stdout.String()) +} + func (s *SharedServerSuite) TestSchedule_Toggle() { schedId, _, res := s.createSchedule("--interval", "10d") s.NoError(res.Err) @@ -511,6 +1107,166 @@ func (s *SharedServerSuite) TestSchedule_Update() { }, 10*time.Second, 100*time.Millisecond) } +func (s *SharedServerSuite) TestSchedule_UpdateAppliesPriorityAndFairness() { + var updateRequest *workflowservice.UpdateScheduleRequest + s.DevServer.SetServerInterceptor(func( + ctx context.Context, + req any, + info *grpc.UnaryServerInfo, + handler grpc.UnaryHandler, + ) (any, error) { + if request, ok := req.(*workflowservice.UpdateScheduleRequest); ok { + updateRequest = request + } + return handler(ctx, req) + }) + + schedID, schedWorkflowID, res := s.createSchedule("--interval", "10d") + s.NoError(res.Err) + + res = s.updateSchedule( + schedID, schedWorkflowID, + "--interval", "1h", + "--priority-key", "42", + "--fairness-key", "tenant-a", + "--fairness-weight", "2.5", + ) + s.NoError(res.Err) + if updateRequest == nil { + s.Fail("UpdateSchedule request was not captured") + return + } + + priority := updateRequest.GetSchedule().GetAction().GetStartWorkflow().GetPriority() + if priority == nil { + s.Fail("UpdateSchedule request did not include priority") + return + } + s.Equal(int32(42), priority.GetPriorityKey()) + s.Equal("tenant-a", priority.GetFairnessKey()) + s.Equal(float32(2.5), priority.GetFairnessWeight()) +} + +func (s *SharedServerSuite) TestSchedule_UpdateResetsOmittedFairness() { + var updateRequest *workflowservice.UpdateScheduleRequest + s.DevServer.SetServerInterceptor(func( + ctx context.Context, + req any, + info *grpc.UnaryServerInfo, + handler grpc.UnaryHandler, + ) (any, error) { + if request, ok := req.(*workflowservice.UpdateScheduleRequest); ok { + updateRequest = request + } + return handler(ctx, req) + }) + + schedID, schedWorkflowID, res := s.createSchedule( + "--interval", "10d", + "--priority-key", "2", + "--fairness-key", "tenant-a", + "--fairness-weight", "2.5", + ) + s.NoError(res.Err) + + res = s.updateSchedule( + schedID, schedWorkflowID, + "--interval", "1h", + ) + s.NoError(res.Err) + if updateRequest == nil { + s.Fail("UpdateSchedule request was not captured") + return + } + + priority := updateRequest.GetSchedule().GetAction().GetStartWorkflow().GetPriority() + s.Equal(int32(0), priority.GetPriorityKey()) + s.Equal("", priority.GetFairnessKey()) + s.Equal(float32(0), priority.GetFairnessWeight()) +} + +func (s *SharedServerSuite) TestSchedule_UpdateRejectsHeadersBeforeMutation() { + var scheduleRequests atomic.Int32 + s.DevServer.SetServerInterceptor(func( + ctx context.Context, + req any, + info *grpc.UnaryServerInfo, + handler grpc.UnaryHandler, + ) (any, error) { + switch req.(type) { + case *workflowservice.DescribeScheduleRequest, *workflowservice.UpdateScheduleRequest: + scheduleRequests.Add(1) + } + return handler(ctx, req) + }) + + schedID, schedWorkflowID, res := s.createSchedule("--interval", "10d") + s.NoError(res.Err) + scheduleRequests.Store(0) + + res = s.updateSchedule( + schedID, schedWorkflowID, + "--interval", "1h", + "--headers", "example=123", + ) + s.Error(res.Err) + s.ErrorContains(res.Err, "headers are not supported for schedule actions") + s.Equal(int32(0), scheduleRequests.Load()) +} + +func (s *SharedServerSuite) TestSchedule_UpdateHelpExplainsFullReplacementAndPrioritySemantics() { + res := s.Execute("schedule", "update", "--help") + s.NoError(res.Err) + normalizedHelp := strings.Join(strings.Fields(res.Stdout.String()), " ") + s.Contains(res.Stdout.String(), "full replacement") + s.Contains(normalizedHelp, "Any options not provided will be reset to their default values") + s.Contains(res.Stdout.String(), "temporal schedule describe") + s.Contains(normalizedHelp, "Priority key passed to the server") + s.Contains(normalizedHelp, "Lower values have higher priority. Zero uses the server-configured default") + s.NotContains(normalizedHelp, "Positive values are interpreted") + s.NotContains(normalizedHelp, "server-configured priority range") +} + +func (s *SharedServerSuite) TestSchedule_UpdateDoesNotPrompt() { + var updateRequests atomic.Int32 + s.DevServer.SetServerInterceptor(func( + ctx context.Context, + req any, + info *grpc.UnaryServerInfo, + handler grpc.UnaryHandler, + ) (any, error) { + if _, ok := req.(*workflowservice.UpdateScheduleRequest); ok { + updateRequests.Add(1) + } + return handler(ctx, req) + }) + + for _, output := range []string{"text", "json"} { + s.T().Run(output, func(t *testing.T) { + schedID, schedWorkflowID, res := s.createSchedule("--interval", "10d") + if res.Err != nil { + t.Fatalf("schedule create returned an unexpected error: %v", res.Err) + } + updateRequests.Store(0) + + const sentinel = "stdin must remain unread" + s.Stdin.Reset() + _, _ = s.Stdin.WriteString(sentinel) + + res = s.updateSchedule( + schedID, schedWorkflowID, + "--output", output, + "--interval", "1h", + ) + if res.Err != nil { + t.Fatalf("schedule update returned an unexpected error: %v", res.Err) + } + assert.Equal(t, int32(1), updateRequests.Load()) + assert.Equal(t, sentinel, s.Stdin.String()) + }) + } +} + func (s *SharedServerSuite) TestSchedule_Memo_Update() { schedId, schedWfId, res := s.createSchedule("--memo", "bar=1") s.NoError(res.Err) diff --git a/internal/temporalcli/commands.yaml b/internal/temporalcli/commands.yaml index c9ea1f162..4e45ed47c 100644 --- a/internal/temporalcli/commands.yaml +++ b/internal/temporalcli/commands.yaml @@ -3142,6 +3142,9 @@ commands: Schedule memo and search attributes cannot be updated with this command. They are set only during Schedule creation and are not affected by updates. + + For field-preserving changes to individual fields, use + `temporal schedule patch`. option-sets: - schedule-configuration - schedule-id @@ -3149,6 +3152,152 @@ commands: - shared-workflow-start - payload-input + - name: temporal schedule patch + summary: Change selected Schedule fields + description: | + Change selected fields on an existing Schedule while preserving + unspecified fields. + + For example: + + ``` + temporal schedule patch \ + --schedule-id "YourScheduleId" \ + --notes "Runs every hour" \ + --interval "1h" + ``` + + A successful command confirms that the patch was submitted, not that it + has been applied on every backend. + + When none of `--calendar`, `--cron`, or `--interval` is supplied, + existing calendar, cron, and interval specifications are preserved. + Supplying any of them replaces all existing calendar, cron, and interval + specifications. `--spec-clear-all` removes all existing calendar, cron, + and interval specifications. + Exclusion calendars, start time, end time, jitter, and time zone are + preserved unless separately changed. + option-sets: + - schedule-id + - overlap-policy + options: + - name: catchup-window + type: duration + description: Maximum catch-up time for when the Service is unavailable. + - name: unset-catchup-window + type: bool + description: Restore the default catch-up window behavior. + - name: pause-on-failure + type: bool + description: Pause the Schedule after Workflow failures. + - name: notes + type: string + description: Set the Schedule notes field. + - name: unset-notes + type: bool + description: Clear the Schedule notes field. + - name: paused + type: bool + description: Set whether the Schedule is paused. + - name: remaining-actions + type: int + description: Total allowed actions. Zero means unlimited. + - name: calendar + type: string[] + description: | + Calendar JSON specification. May be passed multiple times. + Supplying any calendar, cron, or interval value replaces all existing + calendar, cron, and interval specifications. + - name: cron + type: string[] + description: | + Cron expression. May be passed multiple times. + Supplying any calendar, cron, or interval value replaces all existing + calendar, cron, and interval specifications. + - name: interval + type: string[] + description: | + Interval specification. May be passed multiple times. + Supplying any calendar, cron, or interval value replaces all existing + calendar, cron, and interval specifications. + - name: spec-clear-all + type: bool + description: Clear all calendar, cron, and interval specifications from the Schedule Spec. Exclusion calendars and other Schedule Spec fields are preserved. Requires the resulting Schedule to be paused. + - name: start-time + type: timestamp + description: Set the Schedule start time. + - name: unset-start-time + type: bool + description: Clear the Schedule start time. + - name: end-time + type: timestamp + description: Set the Schedule end time. + - name: unset-end-time + type: bool + description: Clear the Schedule end time. + - name: jitter + type: duration + description: Set the Schedule jitter. + - name: unset-jitter + type: bool + description: Clear the Schedule jitter. + - name: time-zone + type: string + description: Set the Schedule time zone. + - name: unset-time-zone + type: bool + description: Restore default Schedule time zone interpretation. + - name: workflow-id + type: string + short: w + description: Set the Workflow ID. An empty value is invalid. + - name: type + type: string + description: Set the Workflow Type name. An empty value is invalid. + aliases: + - name + - name: task-queue + type: string + short: t + description: Set the Workflow Task queue. An empty value is invalid. + - name: execution-timeout + type: duration + description: Set the Workflow Execution timeout. + - name: unset-execution-timeout + type: bool + description: Remove the explicit Workflow Execution timeout. + - name: run-timeout + type: duration + description: Set the Workflow Run timeout. + - name: unset-run-timeout + type: bool + description: Restore the inherited Workflow Run timeout. + - name: task-timeout + type: duration + default: 10s + description: Set the Workflow Task timeout. + - name: unset-task-timeout + type: bool + description: Restore the 10-second default Workflow Task timeout. + - name: static-summary + type: string + experimental: true + description: | + Set the static Workflow summary for human consumption in UIs. + Uses Temporal Markdown formatting, should be a single line. + - name: unset-static-summary + type: bool + description: Remove the static Workflow summary. + - name: static-details + type: string + experimental: true + description: | + Set the static Workflow details for human consumption in UIs. + Uses Temporal Markdown formatting, may be multiple lines. + - name: unset-static-details + type: bool + description: Remove the static Workflow details. + - name: temporal server summary: Run Temporal Server description: | @@ -5505,18 +5654,17 @@ option-sets: - name: priority-key type: int description: | - Priority key (1-5, lower numbers = higher priority). - Tasks in a queue should be processed in close-to-priority-order. - Default is 3 when not specified. + Priority key passed to the server. Lower values have higher priority. + Zero uses the server-configured default. - name: fairness-key type: string description: | - Fairness key (max 64 bytes) for proportional task dispatch. + Fairness key for proportional task dispatch. Tasks with same key share capacity based on their weight. - name: fairness-weight type: float description: | - Weight [0.001-1000] for this fairness key. + Weight for this fairness key. Keys are dispatched proportionally to their weights. - name: workflow-start