Files
michaely520 bbc86b7eee Resend parent workflow asynchronously during standby child completion verification (#11424)
## Problem

A standby child workflow's `CloseExecutionTask` verifies its parent
recorded the completion, and past
`MaxLocalParentWorkflowVerificationDuration` also resends the parent
from the active cluster. That resend is a cross-cluster state sync plus
a possibly paginated history backfill — minutes of work — but the whole
call was bounded by the standby task's hard-coded **3s** `taskTimeout`.
Measured at the active cluster's history shard, the deadline arriving
there was `2.999s`. So the resend never completed.

## Change

- **Run the resend in the background**, bounded by
`ReplicationTaskApplyTimeout` — the setting that already bounds this
same work on the replication stream. The verify RPC returns immediately
and the standby task retries until the parent lands, so it never holds a
transfer-queue worker for the sync.
- The background context is **detached from the request** (gRPC cancels
that when the handler returns) and **rooted at the shard lifecycle**, so
the work stops with the shard.
- **One in-flight resend per parent**, tracked in a shard-level map, and
at most `history.parentWorkflowResendMaxInFlight` (8) concurrent resends
per shard. Callers retry while an earlier resend runs; without this the
test measured 5 full state fetches where 1 suffices. The cap bounds the
goroutines this path can create.
- **Lifted two client ceilings** so the deadline can actually propagate
— `admin.SyncWorkflowState` (was 10s) and `history.SyncWorkflowState`
(was 30s) now share a `DefaultStateSyncTimeout` backstop. This also
fixes the same 10s cap on the replication stream's
`ExecutableTaskImpl.SyncState`, where production's 5m setting was never
reachable either.
- Metrics:
`parent_workflow_resend_{attempts,skipped,limited,failures,latency}`.
Async failures reach no caller, so `_failures` is the alert signal;
`_limited` means the shard is shedding resends. The background goroutine
recovers panics, which would otherwise take down the process.

Also fixes the history-client codegen template, which hardcoded
`createContext` and silently ignored the timeout-tier field.

## Rollout

`history.enableAsyncParentWorkflowResend`, **default false**. Disabled =
the previous inline behavior, bounded by the caller's task deadline. Opt
in per cell.

## Testing

Unit tests cover the inline, async, and per-parent-dedup paths.

An xdc test (added in abae9cec, removed in b6eb6631) withholds the
parent's replication tasks so the child *must* pull it, asserts the
parent is absent from the standby, then stalls the active cluster's
`SyncWorkflowState` for 4 minutes:

```
--- PASS: TestChildPullsParentWhenParentReplicationIsWithheld (286.03s)
incoming-ctx-remaining: 4m59.999859334s    (2.999s before this change)
sync-state-calls:       1                  (5 without the per-parent guard)
dropped-parent-tasks:   9
```

During the stall, 4 verify RPCs reached the standby parent shard (t+0,
+50s, +101s, +169s) and exactly 1 `SyncWorkflowState` reached the active
cluster: the task retried and the guard turned the retries away.

4 minutes exceeds every deadline that previously bounded this path (3s /
10s / 30s) with ~1m headroom against the 5m setting, so the setting is
demonstrably what governs.

To reproduce: `git revert b6eb6631`, then
`go test -tags test_dep ./tests/xdc/ -run
TestVerifyChildCompletionParentResendSuite -timeout 30m`

## Known gaps

- Concurrency across *distinct* parents is unbounded (ordinary fan-out,
not amplification).
- When the parent is deleted on the source, the async path can't report
that back, so the child retries to the 15m discard instead of finishing
immediately. The `workflowNotFoundCache` TODO already in this file would
address it.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 12:53:15 -07:00

659 lines
21 KiB
Go

package main
import (
"cmp"
"flag"
"fmt"
"io"
"log"
"reflect"
"slices"
"strings"
"go.temporal.io/api/operatorservice/v1"
taskqueuepb "go.temporal.io/api/taskqueue/v1"
"go.temporal.io/api/workflowservice/v1"
"go.temporal.io/server/cmd/tools/codegen"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/reflect/protoregistry"
"go.temporal.io/server/api/adminservice/v1"
"go.temporal.io/server/api/historyservice/v1"
"go.temporal.io/server/api/matchingservice/v1"
)
type (
service struct {
name string
clientType reflect.Type
clientGenerator func(io.Writer, service) error
}
fieldWithPath struct {
field *reflect.StructField
path string
}
)
func (f fieldWithPath) found() bool {
return f.path != ""
}
var (
services = []service{
{
name: "frontend",
clientType: reflect.TypeFor[*workflowservice.WorkflowServiceClient](),
clientGenerator: generateFrontendOrAdminClient,
},
{
name: "admin",
clientType: reflect.TypeFor[*adminservice.AdminServiceClient](),
clientGenerator: generateFrontendOrAdminClient,
},
{
name: "operator",
clientType: reflect.TypeFor[*operatorservice.OperatorServiceClient](),
clientGenerator: generateFrontendOrAdminClient,
},
{
name: "history",
clientType: reflect.TypeFor[*historyservice.HistoryServiceClient](),
clientGenerator: generateHistoryClient,
},
{
name: "matching",
clientType: reflect.TypeFor[*matchingservice.MatchingServiceClient](),
clientGenerator: generateMatchingClient,
},
}
longPollContext = map[string]bool{
"client.frontend.ListArchivedWorkflowExecutions": true,
"client.frontend.PollActivityTaskQueue": true,
"client.frontend.PollWorkflowTaskQueue": true,
"client.matching.GetTaskQueueUserData": true,
"client.matching.ListNexusEndpoints": true,
}
largeTimeoutContext = map[string]bool{
"client.admin.GetReplicationMessages": true,
}
// stateSyncTimeoutContext are the cross-cluster workflow state sync hops, whose callers set a
// deadline that can exceed even the large timeout. DefaultStateSyncTimeout is only a backstop.
stateSyncTimeoutContext = map[string]bool{
"client.admin.SyncWorkflowState": true,
"client.history.SyncWorkflowState": true,
}
longPollRetryPolicy = map[string]string{
"retryableClient.matching.PollWorkflowTaskQueue": "pollPolicy",
"retryableClient.matching.PollActivityTaskQueue": "pollPolicy",
"retryableClient.matching.PollNexusTaskQueue": "pollPolicy",
}
ignoreMethod = map[string]bool{
// TODO stream APIs are not supported. do not generate.
"client.admin.StreamWorkflowReplicationMessages": true,
"metricsClient.admin.StreamWorkflowReplicationMessages": true,
"retryableClient.admin.StreamWorkflowReplicationMessages": true,
// TODO(bergundy): Allow specifying custom routing for streaming messages.
"client.history.StreamWorkflowReplicationMessages": true,
"metricsClient.history.StreamWorkflowReplicationMessages": true,
"retryableClient.history.StreamWorkflowReplicationMessages": true,
// Nexus metrics are an exception since they use the information from the request.
"metricsClient.history.StartNexusOperation": true,
"metricsClient.history.CancelNexusOperation": true,
// these need to pick a partition. too complicated.
"client.matching.AddActivityTask": true,
"client.matching.AddWorkflowTask": true,
"client.matching.PollActivityTaskQueue": true,
"client.matching.PollWorkflowTaskQueue": true,
"client.matching.QueryWorkflow": true,
"client.matching.DispatchNexusTask": true,
"client.matching.PollNexusTaskQueue": true,
// these do forwarding stats. too complicated.
"metricsClient.matching.AddActivityTask": true,
"metricsClient.matching.AddWorkflowTask": true,
"metricsClient.matching.PollActivityTaskQueue": true,
"metricsClient.matching.PollWorkflowTaskQueue": true,
"metricsClient.matching.QueryWorkflow": true,
"metricsClient.matching.DispatchNexusTask": true,
"metricsClient.matching.PollNexusTaskQueue": true,
}
// Fields to ignore when looking for the routing fields in a request object.
ignoreField = map[string]bool{
// this is the workflow that sent a signal
"SignalWorkflowExecutionRequest.ExternalWorkflowExecution": true,
// this is the workflow that sent a cancel request
"RequestCancelWorkflowExecutionRequest.ExternalWorkflowExecution": true,
// this is the workflow that sent a terminate
"TerminateWorkflowExecutionRequest.ExternalWorkflowExecution": true,
// this is the parent for starting a child workflow
"StartWorkflowExecutionRequest.ParentExecutionInfo": true,
// this is the root for starting a child workflow
"StartWorkflowExecutionRequest.RootExecutionInfo": true,
// these get routed to the parent
"RecordChildExecutionCompletedRequest.ChildExecution": true,
"VerifyChildExecutionCompletionRecordedRequest.ChildExecution": true,
}
)
var historyRoutingProtoExtension = func() protoreflect.ExtensionType {
ext, err := protoregistry.GlobalTypes.FindExtensionByName("temporal.server.api.historyservice.v1.routing")
if err != nil {
log.Fatalf("Error finding extension: %s", err)
}
return ext
}()
func writeTemplatedCode(w io.Writer, service service, tmpl string) {
codegen.FatalIfErr(codegen.GenerateTemplateToWriter(tmpl, map[string]string{
"ServiceName": service.name,
"ServicePackagePath": service.clientType.Elem().PkgPath(),
}, w))
}
func verifyFieldExists(t reflect.Type, path string) {
pathPrefix := t.String()
parts := strings.Split(path, ".")
for i, part := range parts {
if t.Kind() != reflect.Struct {
codegen.Fatalf("%s is not a struct", pathPrefix)
}
fieldName := codegen.SnakeCaseToPascalCase(part)
f, ok := t.FieldByName(fieldName)
if !ok {
codegen.Fatalf("%s has no field named %s", pathPrefix, fieldName)
}
if i == len(parts)-1 {
return
}
ft := f.Type
if ft.Kind() != reflect.Pointer {
codegen.Fatalf("%s.%s is not a struct pointer", pathPrefix, fieldName)
}
t = ft.Elem()
pathPrefix += "." + fieldName
}
}
func findNestedField(t reflect.Type, name string, path string, maxDepth int) []fieldWithPath {
if t.Kind() != reflect.Struct || maxDepth <= 0 {
return nil
}
var out []fieldWithPath
for f := range t.Fields() {
if ignoreField[t.Name()+"."+f.Name] {
continue
}
if f.Name == name {
out = append(out, fieldWithPath{field: &f, path: path + ".Get" + name + "()"})
}
ft := f.Type
if ft.Kind() == reflect.Pointer {
out = append(out, findNestedField(ft.Elem(), name, path+".Get"+f.Name+"()", maxDepth-1)...)
}
}
return out
}
func findOneNestedField(t reflect.Type, name string, path string, maxDepth int) fieldWithPath {
fields := findNestedField(t, name, path, maxDepth)
if len(fields) == 0 {
codegen.Fatalf("couldn't find %s in %s", name, t)
} else if len(fields) > 1 {
codegen.Fatalf("found more than one %s in %s (%v)", name, t, fields)
}
return fields[0]
}
func tryFindOneNestedField(t reflect.Type, name string, path string, maxDepth int) fieldWithPath {
fields := findNestedField(t, name, path, maxDepth)
if len(fields) == 0 {
return fieldWithPath{}
} else if len(fields) > 1 {
codegen.Fatalf("found more than one %s in %s (%v)", name, t, fields)
}
return fields[0]
}
func historyRoutingOptions(reqType reflect.Type) *historyservice.RoutingOptions {
t := reqType.Elem() // we know it's a pointer
inst := reflect.New(t)
reflectable, ok := inst.Interface().(interface{ ProtoReflect() protoreflect.Message })
if !ok {
log.Fatalf("Request has no ProtoReflect method %s", t)
}
opts := reflectable.ProtoReflect().Descriptor().Options()
// Retrieve the value of the custom option
optionValue := proto.GetExtension(opts, historyRoutingProtoExtension)
if optionValue == nil {
log.Fatalf("Got nil while retrieving extension from options")
}
routingOptions := optionValue.(*historyservice.RoutingOptions)
if routingOptions == nil {
log.Fatalf("Request has no routing options: %s", t)
}
return routingOptions
}
func toGetter(snake string) string {
parts := strings.Split(snake, ".")
for i, part := range parts {
parts[i] = "Get" + codegen.SnakeCaseToPascalCase(part) + "()"
}
return "request." + strings.Join(parts, ".")
}
func makeGetHistoryClient(reqType reflect.Type, routingOptions *historyservice.RoutingOptions) string {
t := reqType.Elem() // we know it's a pointer
if routingOptions.AnyHost && routingOptions.ShardId != "" && routingOptions.WorkflowId != "" && routingOptions.TaskToken != "" && routingOptions.TaskInfos != "" && routingOptions.ChasmComponentRef != "" {
log.Fatalf("Found more than one routing directive in %s", t)
}
if routingOptions.AnyHost {
return "shardID := c.getRandomShard()"
}
if routingOptions.ShardId != "" {
verifyFieldExists(t, routingOptions.ShardId)
return "shardID := " + toGetter(routingOptions.ShardId)
}
if routingOptions.WorkflowId != "" {
namespaceIdField := routingOptions.NamespaceId
if namespaceIdField == "" {
namespaceIdField = "namespace_id"
}
verifyFieldExists(t, namespaceIdField)
verifyFieldExists(t, routingOptions.WorkflowId)
return fmt.Sprintf("shardID := c.shardIDFromWorkflowID(%s, %s)", toGetter(namespaceIdField), toGetter(routingOptions.WorkflowId))
}
if routingOptions.TaskToken != "" {
namespaceIdField := routingOptions.NamespaceId
if namespaceIdField == "" {
namespaceIdField = "namespace_id"
}
verifyFieldExists(t, namespaceIdField)
verifyFieldExists(t, routingOptions.TaskToken)
return fmt.Sprintf(`taskToken, err := c.tokenSerializer.Deserialize(%s)
if err != nil {
return nil, serviceerror.NewInvalidArgument("error deserializing task token")
}
var namespaceID string
var businessID string
if len(taskToken.GetComponentRef()) > 0 {
ref, err := c.tokenSerializer.DeserializeChasmComponentRef(taskToken.GetComponentRef())
if err != nil {
return nil, err
}
namespaceID = ref.GetNamespaceId()
businessID = ref.GetBusinessId()
} else {
namespaceID = %s
businessID = taskToken.GetWorkflowId()
}
shardID := c.shardIDFromWorkflowID(namespaceID, businessID)
`, toGetter(routingOptions.TaskToken), toGetter(namespaceIdField))
}
if routingOptions.ChasmComponentRef != "" {
verifyFieldExists(t, routingOptions.ChasmComponentRef)
return fmt.Sprintf(`ref, err := c.tokenSerializer.DeserializeChasmComponentRef(%s)
if err != nil {
return nil, serviceerror.NewInvalidArgument("error deserializing component ref")
}
shardID := c.shardIDFromWorkflowID(ref.GetNamespaceId(), ref.GetBusinessId())
`, toGetter(routingOptions.ChasmComponentRef))
}
if routingOptions.TaskInfos != "" {
verifyFieldExists(t, routingOptions.TaskInfos)
p := toGetter(routingOptions.TaskInfos)
// slice needs a tiny bit of extra handling for namespace
return fmt.Sprintf(`// All workflow IDs are in the same shard per request
if len(%s) == 0 {
return nil, serviceerror.NewInvalidArgument("missing TaskInfos")
}
shardID := c.shardIDFromWorkflowID(%s[0].NamespaceId, %s[0].WorkflowId)`, p, p, p)
}
log.Fatalf("No routing directive specified on %s", t)
return ""
}
func makeGetMatchingClient(reqType reflect.Type) string {
// this magically figures out how to get a MatchingServiceClient from a request
t := reqType.Elem() // we know it's a pointer
var nsID, tqp, tq, tqt fieldWithPath
switch t.Name() {
case "GetBuildIdTaskQueueMappingRequest":
// Pick a random node for this request, it's not associated with a specific task queue.
tq = fieldWithPath{path: "fmt.Sprintf(\"not-applicable-%d\", rand.Int())"}
tqt = fieldWithPath{path: "enumspb.TASK_QUEUE_TYPE_UNSPECIFIED"}
nsID = findOneNestedField(t, "NamespaceId", "request", 1)
case "UpdateTaskQueueUserDataRequest",
"ReplicateTaskQueueUserDataRequest",
"RecordWorkerHeartbeatRequest",
"ListWorkersRequest",
"CountWorkersRequest",
"DescribeWorkerRequest":
// Always route these requests to the same matching node by namespace.
tq = fieldWithPath{path: "\"not-applicable\""}
tqt = fieldWithPath{path: "enumspb.TASK_QUEUE_TYPE_UNSPECIFIED"}
nsID = findOneNestedField(t, "NamespaceId", "request", 1)
case "GetWorkerBuildIdCompatibilityRequest",
"UpdateWorkerBuildIdCompatibilityRequest",
"RespondQueryTaskCompletedRequest",
"ListTaskQueuePartitionsRequest",
"SyncDeploymentUserDataRequest",
"CheckTaskQueueUserDataPropagationRequest",
"ApplyTaskQueueUserDataReplicationEventRequest",
"GetWorkerVersioningRulesRequest",
"UpdateWorkerVersioningRulesRequest",
"UpdateFairnessStateRequest",
"UpdateTaskQueueConfigRequest":
tq = findOneNestedField(t, "TaskQueue", "request", 2)
tqt = fieldWithPath{path: "enumspb.TASK_QUEUE_TYPE_WORKFLOW"}
nsID = findOneNestedField(t, "NamespaceId", "request", 1)
case "DispatchNexusTaskRequest",
"PollNexusTaskQueueRequest",
"RespondNexusTaskCompletedRequest",
"RespondNexusTaskFailedRequest":
tq = findOneNestedField(t, "TaskQueue", "request", 2)
tqt = fieldWithPath{path: "enumspb.TASK_QUEUE_TYPE_NEXUS"}
nsID = findOneNestedField(t, "NamespaceId", "request", 1)
case "CreateNexusEndpointRequest",
"UpdateNexusEndpointRequest",
"ListNexusEndpointsRequest",
"DeleteNexusEndpointRequest":
// Always route these requests to the same matching node for all namespaces.
tq = fieldWithPath{path: `"not-applicable"`}
tqt = fieldWithPath{path: "enumspb.TASK_QUEUE_TYPE_UNSPECIFIED"}
nsID = fieldWithPath{path: `"not-applicable"`}
default:
tqp = tryFindOneNestedField(t, "TaskQueuePartition", "request", 1)
tq = findOneNestedField(t, "TaskQueue", "request", 2)
tqt = findOneNestedField(t, "TaskQueueType", "request", 2)
nsID = findOneNestedField(t, "NamespaceId", "request", 1)
}
if !nsID.found() {
codegen.Fatalf("I don't know how to get a client from a %s", t)
}
if tqp.found() {
return fmt.Sprintf(
`p := tqid.PartitionFromPartitionProto(%s, %s)
client, err := c.getClientForTaskQueuePartition(p)`,
tqp.path, nsID.path)
}
if tq.found() && tqt.found() {
partitionMaker := fmt.Sprintf("tqid.PartitionFromProto(%s, %s, %s)", tq.path, nsID.path, tqt.path)
// Some task queue fields are full messages, some are just strings
isTaskQueueMessage := tq.field != nil && tq.field.Type == reflect.TypeFor[*taskqueuepb.TaskQueue]()
if !isTaskQueueMessage {
partitionMaker = fmt.Sprintf("tqid.NormalPartitionFromRpcName(%s, %s, %s)", tq.path, nsID.path, tqt.path)
}
return fmt.Sprintf(
`p, err := %s
if err != nil {
return nil, err
}
client, err := c.getClientForTaskQueuePartition(p)`,
partitionMaker)
}
panic("I don't know how to get a client from a " + t.String())
}
func writeTemplatedMethod(w io.Writer, service service, impl string, m reflect.Method, tmpl string) {
key := fmt.Sprintf("%s.%s.%s", impl, service.name, m.Name)
if ignoreMethod[key] {
return
}
mt := m.Type // should look like: func(context.Context, request reqType, opts []grpc.CallOption) (respType, error)
if !mt.IsVariadic() ||
mt.NumIn() != 3 ||
mt.NumOut() != 2 ||
mt.In(0).String() != "context.Context" ||
mt.Out(1).String() != "error" {
panic(key + " doesn't look like a grpc handler method")
}
reqType := mt.In(1)
respType := mt.Out(0)
fields := map[string]string{
"Method": m.Name,
"RequestType": reqType.String(),
"ResponseType": respType.String(),
"MetricPrefix": fmt.Sprintf("%s%sClient", strings.ToUpper(service.name[:1]), service.name[1:]),
"RetryPolicy": cmp.Or(longPollRetryPolicy[key], "policy"),
}
if longPollContext[key] {
fields["LongPoll"] = "LongPoll"
}
if largeTimeoutContext[key] {
fields["WithLargeTimeout"] = "WithLargeTimeout"
}
if stateSyncTimeoutContext[key] {
fields["WithLargeTimeout"] = "WithStateSyncTimeout"
}
if impl == "client" {
if service.name == "history" {
routingOptions := historyRoutingOptions(reqType)
if routingOptions.Custom {
return
}
fields["GetClient"] = makeGetHistoryClient(reqType, routingOptions)
} else if service.name == "matching" {
fields["GetClient"] = makeGetMatchingClient(reqType)
}
}
codegen.FatalIfErr(codegen.GenerateTemplateToWriter(tmpl, fields, w))
}
func writeTemplatedMethods(w io.Writer, service service, impl string, tmpl string) {
sType := service.clientType.Elem()
for method := range sType.Methods() {
writeTemplatedMethod(w, service, impl, method, tmpl)
}
}
func generateFrontendOrAdminClient(w io.Writer, service service) error {
writeTemplatedCode(w, service, `// Code generated by cmd/tools/genrpcwrappers. DO NOT EDIT.
package {{.ServiceName}}
import (
"context"
"{{.ServicePackagePath}}"
"google.golang.org/grpc"
)
`)
writeTemplatedMethods(w, service, "client", `
func (c *clientImpl) {{.Method}}(
ctx context.Context,
request {{.RequestType}},
opts ...grpc.CallOption,
) ({{.ResponseType}}, error) {
ctx, cancel := c.create{{or .LongPoll ""}}Context{{or .WithLargeTimeout ""}}(ctx)
defer cancel()
return c.client.{{.Method}}(ctx, request, opts...)
}
`)
return nil
}
func generateHistoryClient(w io.Writer, service service) error {
writeTemplatedCode(w, service, `// Code generated by cmd/tools/genrpcwrappers. DO NOT EDIT.
package {{.ServiceName}}
import (
"context"
"go.temporal.io/api/serviceerror"
"{{.ServicePackagePath}}"
"google.golang.org/grpc"
)
`)
writeTemplatedMethods(w, service, "client", `
func (c *clientImpl) {{.Method}}(
ctx context.Context,
request {{.RequestType}},
opts ...grpc.CallOption,
) ({{.ResponseType}}, error) {
{{.GetClient}}
var response {{.ResponseType}}
op := func(ctx context.Context, client historyservice.HistoryServiceClient) error {
var err error
ctx, cancel := c.createContext{{or .WithLargeTimeout ""}}(ctx)
defer cancel()
response, err = client.{{.Method}}(ctx, request, opts...)
return err
}
if err := c.executeWithRedirect(ctx, shardID, op); err != nil {
return nil, err
}
return response, nil
}
`)
// TODO: some methods call client.{{.Method}} directly and do not use executeWithRedirect. should we preserve this?
// GetDLQReplicationMessages
// GetDLQMessages
// PurgeDLQMessages
// MergeDLQMessages
return nil
}
func generateMatchingClient(w io.Writer, service service) error {
writeTemplatedCode(w, service, `// Code generated by cmd/tools/genrpcwrappers. DO NOT EDIT.
package {{.ServiceName}}
import (
"context"
"fmt"
"math/rand"
enumspb "go.temporal.io/api/enums/v1"
"{{.ServicePackagePath}}"
"go.temporal.io/server/common/tqid"
"google.golang.org/grpc"
)
`)
writeTemplatedMethods(w, service, "client", `
func (c *clientImpl) {{.Method}}(
ctx context.Context,
request {{.RequestType}},
opts ...grpc.CallOption,
) ({{.ResponseType}}, error) {
{{.GetClient}}
if err != nil {
return nil, err
}
ctx, cancel := c.create{{or .LongPoll ""}}Context(ctx)
defer cancel()
return client.{{.Method}}(ctx, request, opts...)
}
`)
return nil
}
func generateMetricClient(w io.Writer, service service) error {
writeTemplatedCode(w, service, `// Code generated by cmd/tools/genrpcwrappers. DO NOT EDIT.
package {{.ServiceName}}
import (
"context"
"{{.ServicePackagePath}}"
"google.golang.org/grpc"
)
`)
writeTemplatedMethods(w, service, "metricsClient", `
func (c *metricClient) {{.Method}}(
ctx context.Context,
request {{.RequestType}},
opts ...grpc.CallOption,
) (_ {{.ResponseType}}, retError error) {
metricsHandler, startTime := c.startMetricsRecording(ctx, "{{.MetricPrefix}}{{.Method}}")
defer func() {
c.finishMetricsRecording(metricsHandler, startTime, retError)
}()
return c.client.{{.Method}}(ctx, request, opts...)
}
`)
return nil
}
func generateRetryableClient(w io.Writer, service service) error {
writeTemplatedCode(w, service, `// Code generated by cmd/tools/genrpcwrappers. DO NOT EDIT.
package {{.ServiceName}}
import (
"context"
"{{.ServicePackagePath}}"
"google.golang.org/grpc"
"go.temporal.io/server/common/backoff"
)
`)
writeTemplatedMethods(w, service, "retryableClient", `
func (c *retryableClient) {{.Method}}(
ctx context.Context,
request {{.RequestType}},
opts ...grpc.CallOption,
) ({{.ResponseType}}, error) {
var resp {{.ResponseType}}
op := func(ctx context.Context) error {
var err error
resp, err = c.client.{{.Method}}(ctx, request, opts...)
return err
}
err := backoff.ThrottleRetryContext(ctx, op, c.{{.RetryPolicy}}, c.isRetryable)
return resp, err
}
`)
return nil
}
func main() {
serviceFlag := flag.String("service", "", "which service to generate rpc client wrappers for")
flag.Parse()
i := slices.IndexFunc(services, func(s service) bool { return s.name == *serviceFlag })
if i < 0 {
codegen.Fatalf("unknown service: %s", *serviceFlag)
}
svc := services[i]
codegen.GenerateToFile(svc.clientGenerator, svc, "", "client")
codegen.GenerateToFile(generateMetricClient, svc, "", "metric_client")
codegen.GenerateToFile(generateRetryableClient, svc, "", "retryable_client")
}