Files
tasks/common/rpc/interceptor/health_check.go
Nick BeaumontandGitHub 500f2a6e6c Handle historyservice.GetWorkflowExecutionHistoryRequest in health check interceptor (#9849)
## What changed?
Added `*historyservice.GetWorkflowExecutionHistoryRequest` to the set of
special case APIs in the health check interceptor.

I also removed the dependency on the neighboring interceptor since the
total lines saved were low, and the pattern is clearer with unified
handling for all the request types in a single switch block.

## Why?
historyservice wraps the workflowservice
GetWorkflowExecutionHistoryRequest with its own struct, so its type will
not match. This leads to incorrectly marking it as a non-polling API,
and causing it to trip the health alarm.

## How did you test it?
- [x] built
- [x] run locally and tested manually
- [x] covered by existing tests
2026-04-07 15:00:58 -07:00

227 lines
6.6 KiB
Go

package interceptor
import (
"context"
"fmt"
"strings"
"sync"
"time"
"go.temporal.io/api/serviceerror"
"go.temporal.io/api/workflowservice/v1"
commonspb "go.temporal.io/server/api/common/v1"
"go.temporal.io/server/api/historyservice/v1"
"go.temporal.io/server/common"
"go.temporal.io/server/common/aggregate"
"go.temporal.io/server/common/dynamicconfig"
"go.temporal.io/server/common/log"
"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/reflect/protoregistry"
"google.golang.org/protobuf/types/descriptorpb"
)
const (
chasmProtoPrefix = "temporal/server/chasm/lib/"
serviceProtoSuffix = "/service.proto"
)
type (
// HealthCheckInterceptor is a gRPC interceptor that records health metrics
HealthCheckInterceptor struct {
healthSignalAggregator HealthSignalAggregator
}
// HealthSignalAggregator interface for aggregating health signals
HealthSignalAggregator interface {
Record(latency time.Duration, err error)
AverageLatency() float64
ErrorRatio() float64
}
// HealthSignalAggregatorImpl implements HealthSignalAggregator
healthSignalAggregatorImpl struct {
status int32
aggregatorEnabled dynamicconfig.BoolPropertyFn
latencyAverage aggregate.MovingWindowAverage
errorRatio aggregate.MovingWindowAverage
logger log.Logger
}
)
// excludedAPIs maps full method names to true if they should be excluded from health signals.
// This includes both long-polling APIs and system APIs.
// Built lazily on first use from proto method options.
var (
excludedAPIs map[string]bool
excludedAPIsOnce sync.Once
)
func initExcludedAPIs() {
excludedAPIs = make(map[string]bool)
excludedCategories := map[commonspb.ApiCategory]bool{
commonspb.API_CATEGORY_LONG_POLL: true,
commonspb.API_CATEGORY_SYSTEM: true,
}
// Process HistoryService explicitly.
processServiceFile(historyservice.File_temporal_server_api_historyservice_v1_service_proto, excludedCategories)
// Auto-detect all registered chasm/lib service files.
// New services under chasm/lib are picked up automatically without code changes here.
protoregistry.GlobalFiles.RangeFiles(func(fd protoreflect.FileDescriptor) bool {
path := string(fd.Path())
if strings.HasPrefix(path, chasmProtoPrefix) && strings.HasSuffix(path, serviceProtoSuffix) {
processServiceFile(fd, excludedCategories)
}
return true
})
}
// processServiceFile enumerates all methods in a service file and adds excluded categories to excludedAPIs.
func processServiceFile(file protoreflect.FileDescriptor, excludedCategories map[commonspb.ApiCategory]bool) {
services := file.Services()
for i := 0; i < services.Len(); i++ {
service := services.Get(i)
methods := service.Methods()
for j := 0; j < methods.Len(); j++ {
method := methods.Get(j)
opts, ok := method.Options().(*descriptorpb.MethodOptions)
if ok && proto.HasExtension(opts, commonspb.E_ApiCategory) {
categoryOpts, ok := proto.GetExtension(opts, commonspb.E_ApiCategory).(*commonspb.ApiCategoryOptions)
if ok && categoryOpts != nil && excludedCategories[categoryOpts.GetCategory()] {
fullMethod := fmt.Sprintf("/%s/%s", service.FullName(), method.Name())
excludedAPIs[fullMethod] = true
}
}
}
}
}
// isExcludedAPI checks if an API is marked as a non-standard API via proto options.
func isExcludedAPI(fullMethod string) bool {
excludedAPIsOnce.Do(initExcludedAPIs)
return excludedAPIs[fullMethod]
}
// NewHealthCheckInterceptor creates a new health check interceptor
func NewHealthCheckInterceptor(healthSignalAggregator HealthSignalAggregator) *HealthCheckInterceptor {
return &HealthCheckInterceptor{
healthSignalAggregator: healthSignalAggregator,
}
}
// UnaryIntercept implements the gRPC unary interceptor interface
func (h *HealthCheckInterceptor) UnaryIntercept(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (any, error) {
startTime := time.Now()
resp, err := handler(ctx, req)
elapsed := time.Since(startTime)
// Skip health signal recording for non-standard APIs
if isExcludedAPI(info.FullMethod) {
return resp, err
}
if specialCaseAPIIsPolling(req) {
return resp, err
}
// Record health signal for standard APIs
h.healthSignalAggregator.Record(elapsed, err)
return resp, err
}
// specialCaseAPIIsPolling checks if an API is a long-polling API and should be excluded from health signals.
// Note that this interceptor may run in multiple Temporal services, so it needs to handle every version of
// each special request type. (for example, historyservice GetWorkflowExecutionHistory vs. workflowservice)
func specialCaseAPIIsPolling(req any) bool {
switch request := req.(type) {
// history
case *historyservice.GetWorkflowExecutionHistoryRequest:
inner := request.GetRequest()
return inner != nil && inner.GetWaitNewEvent()
// frontend
case *workflowservice.GetWorkflowExecutionHistoryRequest:
return request.GetWaitNewEvent()
case *workflowservice.DescribeActivityExecutionRequest:
return len(request.GetLongPollToken()) > 0
default:
return false
}
}
// NewHealthSignalAggregator creates a new instance of HealthSignalAggregatorImpl
func NewHealthSignalAggregator(
logger log.Logger,
aggregatorEnabled dynamicconfig.BoolPropertyFn,
windowSize time.Duration,
maxBufferSize int,
) *healthSignalAggregatorImpl {
ret := &healthSignalAggregatorImpl{
logger: logger,
aggregatorEnabled: aggregatorEnabled,
latencyAverage: aggregate.NewMovingWindowAvgImpl(windowSize, maxBufferSize),
errorRatio: aggregate.NewMovingWindowAvgImpl(windowSize, maxBufferSize),
}
return ret
}
func (s *healthSignalAggregatorImpl) Record(latency time.Duration, err error) {
if !s.aggregatorEnabled() {
s.logger.Debug("health signal aggregator is disabled")
return
}
s.latencyAverage.Record(latency.Milliseconds())
if isUnhealthyError(err) {
s.errorRatio.Record(1)
} else {
s.errorRatio.Record(0)
}
}
func (s *healthSignalAggregatorImpl) AverageLatency() float64 {
if !s.aggregatorEnabled() {
s.logger.Debug("health signal aggregator is disabled")
}
return s.latencyAverage.Average()
}
func (s *healthSignalAggregatorImpl) ErrorRatio() float64 {
if !s.aggregatorEnabled() {
s.logger.Debug("health signal aggregator is disabled")
}
return s.errorRatio.Average()
}
func isUnhealthyError(err error) bool {
if err == nil {
return false
}
if common.IsContextCanceledErr(err) {
return true
}
if common.IsContextDeadlineExceededErr(err) {
return true
}
switch err.(type) {
case *serviceerror.Unavailable,
*serviceerror.DeadlineExceeded,
*serviceerror.Canceled:
return true
}
return false
}