Files
hatchet/pkg/client/client.go
abelanger5 5c5c1aa5a1 feat: more features in the load testing harness (#1691)
* fix: make stripped payload size configurable

* feat: more load test features

* Update cmd/hatchet-loadtest/do.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: try to fix load tests

* increase timeout, update goleak ignores

* fix: data race in scheduler with snapshot input

* fix: logger improvements

* add one more goleak ignore

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-07 21:39:30 -04:00

446 lines
10 KiB
Go

package client
import (
"context"
"crypto/tls"
"fmt"
"net/http"
"time"
grpc_retry "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/retry"
"github.com/rs/zerolog"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/keepalive"
"google.golang.org/grpc/status"
"github.com/hatchet-dev/hatchet/pkg/client/loader"
"github.com/hatchet-dev/hatchet/pkg/client/rest"
cloudrest "github.com/hatchet-dev/hatchet/pkg/client/cloud/rest"
"github.com/hatchet-dev/hatchet/pkg/client/types"
"github.com/hatchet-dev/hatchet/pkg/config/client"
"github.com/hatchet-dev/hatchet/pkg/logger"
"github.com/hatchet-dev/hatchet/pkg/validator"
)
type Client interface {
Admin() AdminClient
Cron() CronClient
Schedule() ScheduleClient
Dispatcher() DispatcherClient
Event() EventClient
Subscribe() SubscribeClient
API() *rest.ClientWithResponses
CloudAPI() *cloudrest.ClientWithResponses
Logger() *zerolog.Logger
TenantId() string
Namespace() string
CloudRegisterID() *string
RunnableActions() []string
}
type clientImpl struct {
conn *grpc.ClientConn
admin AdminClient
cron CronClient
schedule ScheduleClient
dispatcher DispatcherClient
event EventClient
subscribe SubscribeClient
rest *rest.ClientWithResponses
cloudrest *cloudrest.ClientWithResponses
// the tenant id
tenantId string
namespace string
cloudRegisterID *string
runnableActions []string
l *zerolog.Logger
v validator.Validator
}
type ClientOpt func(*ClientOpts)
type filesLoaderFunc func() []*types.Workflow
type ClientOpts struct {
tenantId string
l *zerolog.Logger
v validator.Validator
tls *tls.Config
hostPort string
serverURL string
token string
namespace string
noGrpcRetry bool
sharedMeta map[string]string
cloudRegisterID *string
runnableActions []string
filesLoader filesLoaderFunc
initWorkflows bool
presetWorkerLabels map[string]string
}
func defaultClientOpts(token *string, cf *client.ClientConfigFile) *ClientOpts {
var clientConfig *client.ClientConfig
var err error
configLoader := &loader.ConfigLoader{}
if cf == nil {
// read from environment variables and hostname by default
clientConfig, err = configLoader.LoadClientConfig(token)
if err != nil {
panic(err)
}
} else {
if token != nil {
cf.Token = *token
}
clientConfig, err = loader.GetClientConfigFromConfigFile(cf)
if err != nil {
panic(err)
}
}
logger := logger.NewDefaultLogger("client")
return &ClientOpts{
tenantId: clientConfig.TenantId,
token: clientConfig.Token,
l: &logger,
v: validator.NewDefaultValidator(),
tls: clientConfig.TLSConfig,
hostPort: clientConfig.GRPCBroadcastAddress,
serverURL: clientConfig.ServerURL,
filesLoader: types.DefaultLoader,
namespace: clientConfig.Namespace,
cloudRegisterID: clientConfig.CloudRegisterID,
runnableActions: clientConfig.RunnableActions,
noGrpcRetry: clientConfig.NoGrpcRetry,
sharedMeta: make(map[string]string),
presetWorkerLabels: clientConfig.PresetWorkerLabels,
}
}
// Deprecated: use WithLogger instead
func WithLogLevel(lvl string) ClientOpt {
return func(opts *ClientOpts) {
logger := logger.NewDefaultLogger("client")
lvl, err := zerolog.ParseLevel(lvl)
if err == nil {
logger = logger.Level(lvl)
}
opts.l = &logger
}
}
func WithLogger(l *zerolog.Logger) ClientOpt {
return func(opts *ClientOpts) {
opts.l = l
}
}
func WithTenantId(tenantId string) ClientOpt {
return func(opts *ClientOpts) {
opts.tenantId = tenantId
}
}
func WithHostPort(host string, port int) ClientOpt {
return func(opts *ClientOpts) {
opts.hostPort = fmt.Sprintf("%s:%d", host, port)
}
}
func WithToken(token string) ClientOpt {
return func(opts *ClientOpts) {
opts.token = token
}
}
func WithNamespace(namespace string) ClientOpt {
return func(opts *ClientOpts) {
opts.namespace = namespace + "_"
}
}
func WithSharedMeta(meta map[string]string) ClientOpt {
return func(opts *ClientOpts) {
if opts.sharedMeta == nil {
opts.sharedMeta = make(map[string]string)
}
for k, v := range meta {
opts.sharedMeta[k] = v
}
}
}
func InitWorkflows() ClientOpt {
return func(opts *ClientOpts) {
opts.initWorkflows = true
}
}
type sharedClientOpts struct {
tenantId string
namespace string
l *zerolog.Logger
v validator.Validator
ctxLoader *contextLoader
sharedMeta map[string]string
}
// New creates a new client instance.
func New(fs ...ClientOpt) (Client, error) {
var token *string
initOpts := &ClientOpts{}
for _, f := range fs {
f(initOpts)
}
if initOpts.token != "" {
token = &initOpts.token
}
opts := defaultClientOpts(token, nil)
for _, f := range fs {
f(opts)
}
return newFromOpts(opts)
}
func NewFromConfigFile(cf *client.ClientConfigFile, fs ...ClientOpt) (Client, error) {
opts := defaultClientOpts(nil, cf)
for _, f := range fs {
f(opts)
}
return newFromOpts(opts)
}
func newFromOpts(opts *ClientOpts) (Client, error) {
if opts.token == "" {
return nil, fmt.Errorf("token is required")
}
var transportCreds credentials.TransportCredentials
if opts.tls == nil {
opts.l.Debug().Msgf("connecting to %s without TLS", opts.hostPort)
transportCreds = insecure.NewCredentials()
} else {
opts.l.Debug().Msgf("connecting to %s with TLS server name %s", opts.hostPort, opts.tls.ServerName)
transportCreds = credentials.NewTLS(opts.tls)
}
keepAliveParams := keepalive.ClientParameters{
Time: 10 * time.Second, // grpc.keepalive_time_ms: 10 * 1000
Timeout: 60 * time.Second, // grpc.keepalive_timeout_ms: 60 * 1000
PermitWithoutStream: true, // grpc.keepalive_permit_without_calls: 1
}
grpcOpts := []grpc.DialOption{
grpc.WithTransportCredentials(transportCreds),
grpc.WithKeepaliveParams(keepAliveParams),
}
if !opts.noGrpcRetry {
retryOnCodes := []codes.Code{
codes.ResourceExhausted,
codes.DeadlineExceeded,
codes.FailedPrecondition,
codes.Internal,
codes.Unavailable,
}
retryOpts := []grpc_retry.CallOption{
grpc_retry.WithBackoff(grpc_retry.BackoffExponentialWithJitter(5*time.Second, 0.10)),
grpc_retry.WithMax(5),
grpc_retry.WithPerRetryTimeout(30 * time.Second),
grpc_retry.WithCodes(retryOnCodes...),
grpc_retry.WithOnRetryCallback(grpc_retry.OnRetryCallback(func(ctx context.Context, attempt uint, err error) {
if contains(retryOnCodes, status.Code(err)) {
opts.l.Debug().Msgf("grpc_retry attempt: %d, backoff for %v", attempt, err)
}
})),
}
grpcOpts = append(grpcOpts, grpc.WithChainStreamInterceptor(grpc_retry.StreamClientInterceptor(retryOpts...)))
grpcOpts = append(grpcOpts, grpc.WithChainUnaryInterceptor(grpc_retry.UnaryClientInterceptor(retryOpts...)))
}
conn, err := grpc.NewClient(
opts.hostPort,
grpcOpts...,
)
if err != nil {
return nil, err
}
shared := &sharedClientOpts{
tenantId: opts.tenantId,
namespace: opts.namespace,
l: opts.l,
v: opts.v,
ctxLoader: newContextLoader(opts.token),
sharedMeta: opts.sharedMeta,
}
subscribe := newSubscribe(conn, shared)
admin := newAdmin(conn, shared, subscribe)
dispatcher := newDispatcher(conn, shared, opts.presetWorkerLabels)
event := newEvent(conn, shared)
rest, err := rest.NewClientWithResponses(opts.serverURL, rest.WithRequestEditorFn(func(ctx context.Context, req *http.Request) error {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", opts.token))
return nil
}))
if err != nil {
return nil, fmt.Errorf("could not create rest client: %w", err)
}
cloudrest, err := cloudrest.NewClientWithResponses(opts.serverURL, cloudrest.WithRequestEditorFn(func(ctx context.Context, req *http.Request) error {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", opts.token))
return nil
}))
if err != nil {
return nil, fmt.Errorf("could not create cloud REST client: %w", err)
}
cronClient, err := NewCronClient(rest, opts.l, opts.v, opts.tenantId, opts.namespace)
if err != nil {
return nil, fmt.Errorf("could not create cron client: %w", err)
}
scheduleClient, err := NewScheduleClient(rest, opts.l, opts.v, opts.tenantId, opts.namespace)
if err != nil {
return nil, fmt.Errorf("could not create schedule client: %w", err)
}
// if init workflows is set, then we need to initialize the workflows
if opts.initWorkflows {
if err := initWorkflows(opts.filesLoader, admin); err != nil {
return nil, fmt.Errorf("could not init workflows: %w", err)
}
}
return &clientImpl{
conn: conn,
tenantId: opts.tenantId,
l: opts.l,
admin: admin,
cron: cronClient,
schedule: scheduleClient,
dispatcher: dispatcher,
subscribe: subscribe,
event: event,
v: opts.v,
rest: rest,
cloudrest: cloudrest,
namespace: opts.namespace,
cloudRegisterID: opts.cloudRegisterID,
runnableActions: opts.runnableActions,
}, nil
}
func (c *clientImpl) Admin() AdminClient {
return c.admin
}
func (c *clientImpl) Cron() CronClient {
return c.cron
}
func (c *clientImpl) Schedule() ScheduleClient {
return c.schedule
}
func (c *clientImpl) Dispatcher() DispatcherClient {
return c.dispatcher
}
func (c *clientImpl) Event() EventClient {
return c.event
}
func (c *clientImpl) Subscribe() SubscribeClient {
return c.subscribe
}
func (c *clientImpl) Logger() *zerolog.Logger {
return c.l
}
func (c *clientImpl) API() *rest.ClientWithResponses {
return c.rest
}
func (c *clientImpl) CloudAPI() *cloudrest.ClientWithResponses {
return c.cloudrest
}
func (c *clientImpl) TenantId() string {
return c.tenantId
}
func (c *clientImpl) Namespace() string {
return c.namespace
}
func (c *clientImpl) CloudRegisterID() *string {
return c.cloudRegisterID
}
func (c *clientImpl) RunnableActions() []string {
return c.runnableActions
}
func initWorkflows(fl filesLoaderFunc, adminClient AdminClient) error {
files := fl()
for _, file := range files {
if err := adminClient.PutWorkflow(file); err != nil {
return fmt.Errorf("could not create workflow: %w", err)
}
}
return nil
}
func contains(codes []codes.Code, code codes.Code) bool {
for _, c := range codes {
if c == code {
return true
}
}
return false
}