From b1f194bd70ecb343830ae93190e1abed44a131cc Mon Sep 17 00:00:00 2001 From: Richard Carson Derr Date: Fri, 23 Feb 2024 21:08:42 -0500 Subject: [PATCH] story(health): refactor package to export conjuction functions and basic toggle implementation (#141) * refactor(issue-140): remove k8s inspired types * feat(issue-140): added logical operator helpers for health metrics * chore(docs): updated coverage badge. --------- Co-authored-by: GitHub Action --- README.md | 2 +- .../custom_framework/framework/framework.go | 15 +- example/simple_grpc/main.go | 2 +- grpc/grpc.go | 11 +- grpc/grpc_test.go | 14 +- pkg/health/health.go | 139 +++++------ pkg/health/health_example_test.go | 52 +++++ pkg/health/health_test.go | 216 ++++++++++-------- 8 files changed, 242 insertions(+), 209 deletions(-) create mode 100644 pkg/health/health_example_test.go diff --git a/README.md b/README.md index ff0e425..e49ebed 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go) [![Go Reference](https://pkg.go.dev/badge/github.com/z5labs/bedrock.svg)](https://pkg.go.dev/github.com/z5labs/bedrock) [![Go Report Card](https://goreportcard.com/badge/github.com/z5labs/bedrock)](https://goreportcard.com/report/github.com/z5labs/bedrock) -![Coverage](https://img.shields.io/badge/Coverage-95.0%25-brightgreen) +![Coverage](https://img.shields.io/badge/Coverage-94.8%25-brightgreen) [![build](https://github.com/z5labs/bedrock/actions/workflows/build.yaml/badge.svg)](https://github.com/z5labs/bedrock/actions/workflows/build.yaml) **bedrock provides a minimal, modular and composable foundation for diff --git a/example/custom_framework/framework/framework.go b/example/custom_framework/framework/framework.go index 920a31f..4d81872 100644 --- a/example/custom_framework/framework/framework.go +++ b/example/custom_framework/framework/framework.go @@ -112,34 +112,25 @@ func Rest(cfg io.Reader, f func(context.Context, *http.ServeMux) error) error { return nil, err } - started := &health.Started{} - started.Started() - - liveness := &health.Liveness{} - liveness.Alive() - - readiness := &health.Readiness{} - readiness.Ready() - mux := http.NewServeMux() mux.Handle( "/health/liveness", httpvalidate.Request( - httphealth.NewHandler(liveness), + httphealth.NewHandler(&health.Binary{}), httpvalidate.ForMethods(http.MethodGet), ), ) mux.Handle( "/health/readiness", httpvalidate.Request( - httphealth.NewHandler(readiness), + httphealth.NewHandler(&health.Binary{}), httpvalidate.ForMethods(http.MethodGet), ), ) mux.Handle( "/health/started", httpvalidate.Request( - httphealth.NewHandler(started), + httphealth.NewHandler(&health.Binary{}), httpvalidate.ForMethods(http.MethodGet), ), ) diff --git a/example/simple_grpc/main.go b/example/simple_grpc/main.go index 4593a78..a1d6d25 100644 --- a/example/simple_grpc/main.go +++ b/example/simple_grpc/main.go @@ -48,7 +48,7 @@ func initRuntime(ctx context.Context) (bedrock.Runtime, error) { brgrpc.Service( registerSimpleService, brgrpc.ServiceName("simple"), - brgrpc.Readiness(&health.Readiness{}), + brgrpc.Readiness(&health.Binary{}), ), // register reflection service so you can test this example // via Insomnia, Postman and any other API testing tool that diff --git a/grpc/grpc.go b/grpc/grpc.go index 0527d84..3e3dfa7 100644 --- a/grpc/grpc.go +++ b/grpc/grpc.go @@ -65,7 +65,7 @@ func TransportCredentials(tc credentials.TransportCredentials) RuntimeOption { type serviceOptions struct { name string - readiness *health.Readiness + readiness health.Metric } // ServiceOption are options for configuring the gRPC health service. @@ -79,9 +79,9 @@ func ServiceName(name string) ServiceOption { } // Readiness configures the health readiness metric for the gRPC service. -func Readiness(readiness *health.Readiness) ServiceOption { +func Readiness(m health.Metric) ServiceOption { return func(so *serviceOptions) { - so.readiness = readiness + so.readiness = m } } @@ -89,7 +89,7 @@ func Readiness(readiness *health.Readiness) ServiceOption { func Service(f func(*grpc.Server), opts ...ServiceOption) RuntimeOption { return func(ro *runtimeOptions) { so := serviceOptions{ - readiness: &health.Readiness{}, + readiness: &health.Binary{}, } for _, opt := range opts { opt(&so) @@ -103,7 +103,7 @@ func Service(f func(*grpc.Server), opts ...ServiceOption) RuntimeOption { type serviceHealthMonitor struct { name string - readiness *health.Readiness + readiness health.Metric } type grpcServer interface { @@ -177,7 +177,6 @@ func (rt *Runtime) Run(ctx context.Context) error { monitor := monitor g.Go(func() error { healthy := true - monitor.readiness.Ready() rt.health.SetServingStatus(monitor.name, grpc_health_v1.HealthCheckResponse_SERVING) for { select { diff --git a/grpc/grpc_test.go b/grpc/grpc_test.go index 5d90283..51239e5 100644 --- a/grpc/grpc_test.go +++ b/grpc/grpc_test.go @@ -95,7 +95,7 @@ func TestReadiness(t *testing.T) { func(s *grpc.Server) {}, // No ServiceName is set so this corresponds to // overall server health - Readiness(&health.Readiness{}), + Readiness(&health.Binary{}), ), ) addrCh := make(chan net.Addr) @@ -155,7 +155,7 @@ func TestReadiness(t *testing.T) { }) t.Run("if the health metric is toggled from unhealthy to healthy", func(t *testing.T) { - var readiness health.Readiness + var readiness health.Binary rt := NewRuntime( LogHandler(noop.LogHandler{}), Service( @@ -199,8 +199,8 @@ func TestReadiness(t *testing.T) { if err != nil { return err } - readiness.NotReady() - readiness.Ready() + readiness.Toggle() + readiness.Toggle() client := grpc_health_v1.NewHealthClient(conn) resp, err := client.Check(gctx, &grpc_health_v1.HealthCheckRequest{ @@ -230,7 +230,7 @@ func TestReadiness(t *testing.T) { Service( func(s *grpc.Server) {}, ServiceName("test"), - Readiness(&health.Readiness{}), + Readiness(&health.Binary{}), ), ) addrCh := make(chan net.Addr) @@ -292,7 +292,7 @@ func TestReadiness(t *testing.T) { t.Run("will return not serving", func(t *testing.T) { t.Run("if the health metric returns not healthy", func(t *testing.T) { - var readiness health.Readiness + var readiness health.Binary rt := NewRuntime( LogHandler(noop.LogHandler{}), Service( @@ -335,7 +335,7 @@ func TestReadiness(t *testing.T) { if err != nil { return err } - readiness.NotReady() + readiness.Toggle() <-time.After(200 * time.Millisecond) client := grpc_health_v1.NewHealthClient(conn) diff --git a/pkg/health/health.go b/pkg/health/health.go index a43af5a..30eff60 100644 --- a/pkg/health/health.go +++ b/pkg/health/health.go @@ -7,7 +7,6 @@ package health import ( "context" - "net/http" "sync" ) @@ -16,109 +15,87 @@ type Metric interface { Healthy(context.Context) bool } -// Started is used for signifying that the application -// requires a longer amount of time to initialize. -type Started struct { - mu sync.RWMutex - started bool +// Binary represents a health.Metric that is either healthy or not. +// The default value is represents a healthy state. +type Binary struct { + mu sync.Mutex + unhealthy bool } -// Started marks this metric as "healthy". -func (s *Started) Started() { - s.mu.Lock() - defer s.mu.Unlock() - s.started = true +// Toggle toggles the state of Binary. +func (m *Binary) Toggle() { + m.mu.Lock() + defer m.mu.Unlock() + m.unhealthy = !m.unhealthy } // Healthy implements the Metric interface. -func (s *Started) Healthy(ctx context.Context) bool { - s.mu.RLock() - defer s.mu.RUnlock() - return s.started +func (m *Binary) Healthy(ctx context.Context) bool { + m.mu.Lock() + defer m.mu.Unlock() + return !m.unhealthy } -// ServeHTTP implements the http.Handler interface. -func (s *Started) ServeHTTP(w http.ResponseWriter, req *http.Request) { - started := s.Healthy(req.Context()) - if started { - w.WriteHeader(http.StatusOK) - return - } - w.WriteHeader(http.StatusServiceUnavailable) -} - -// Readiness is used for signifying that the application is -// temporarily unable to serve traffic. -type Readiness struct { - mu sync.RWMutex - ready bool +// AndMetric represents multiple Metrics all and'd together. +type AndMetric struct { + metrics []Metric } -// NotReady marks this metric as "unhealthy". -func (r *Readiness) NotReady() { - r.mu.Lock() - defer r.mu.Unlock() - r.ready = false +// And returns a Metric where all the underlying Metrics healthy +// states are joined together via the logical and (&&) operator. +func And(metrics ...Metric) AndMetric { + return AndMetric{ + metrics: metrics, + } } -// Ready marks this metric as "healthy". -func (r *Readiness) Ready() { - r.mu.Lock() - defer r.mu.Unlock() - r.ready = true +// Healthy implements the Metric interface. +func (m AndMetric) Healthy(ctx context.Context) bool { + for _, metric := range m.metrics { + if !metric.Healthy(ctx) { + return false + } + } + return true } -// Healthy implements the Metric interface. -func (r *Readiness) Healthy(ctx context.Context) bool { - r.mu.RLock() - defer r.mu.RUnlock() - return r.ready +// OrMetric represents multiple Metrics all or'd together. +type OrMetric struct { + metrics []Metric } -// ServeHTTP implements the http.Handler interface. -func (r *Readiness) ServeHTTP(w http.ResponseWriter, req *http.Request) { - ready := r.Healthy(req.Context()) - if ready { - w.WriteHeader(http.StatusOK) - return +// Or returns a Metric where all the underlying Metrics healthy +// states are joined together via the logical or (||) operator. +func Or(metrics ...Metric) OrMetric { + return OrMetric{ + metrics: metrics, } - w.WriteHeader(http.StatusServiceUnavailable) } -// Liveness is used for signifying that the application has transitioned -// to a broken state, and cannot recover execpt by being restarted. -type Liveness struct { - mu sync.RWMutex - alive bool +// Healthy implements the Metric interface. +func (m OrMetric) Healthy(ctx context.Context) bool { + for _, metric := range m.metrics { + if metric.Healthy(ctx) { + return true + } + } + return false } -// Dead marks this metric as "unhealthy". -func (l *Liveness) Dead() { - l.mu.Lock() - defer l.mu.Unlock() - l.alive = false +// NotMetric represents the not'd value of the unerlying Metric. +type NotMetric struct { + metric Metric } -// Alive marks this metric as "healthy". -func (l *Liveness) Alive() { - l.mu.Lock() - defer l.mu.Unlock() - l.alive = true +// And returns a Metric where the underlying Metric healthy state +// is negated with the logical not (!) operator. +func Not(metric Metric) NotMetric { + return NotMetric{ + metric: metric, + } } // Healthy implements the Metric interface. -func (l *Liveness) Healthy(ctx context.Context) bool { - l.mu.RLock() - defer l.mu.RUnlock() - return l.alive -} - -// ServeHTTP implements the http.Handler interface. -func (l *Liveness) ServeHTTP(w http.ResponseWriter, req *http.Request) { - alive := l.Healthy(req.Context()) - if alive { - w.WriteHeader(http.StatusOK) - return - } - w.WriteHeader(http.StatusServiceUnavailable) +func (m NotMetric) Healthy(ctx context.Context) bool { + return !m.metric.Healthy(ctx) } diff --git a/pkg/health/health_example_test.go b/pkg/health/health_example_test.go new file mode 100644 index 0000000..89a385f --- /dev/null +++ b/pkg/health/health_example_test.go @@ -0,0 +1,52 @@ +// Copyright (c) 2024 Z5Labs and Contributors +// +// This software is released under the MIT License. +// https://opensource.org/licenses/MIT + +package health + +import ( + "context" + "fmt" +) + +func ExampleBinary() { + var b Binary + fmt.Println(b.Healthy(context.Background())) + + b.Toggle() + fmt.Println(b.Healthy(context.Background())) + // Output: true + // false +} + +func ExampleAnd() { + var a Binary + var b Binary + b.Toggle() + + ab := And(&a, &b) + fmt.Println(ab.Healthy(context.Background())) + // Output: false +} + +func ExampleOr() { + var a Binary + var b Binary + b.Toggle() + + ob := Or(&a, &b) + fmt.Println(ob.Healthy(context.Background())) + // Output: true +} + +func ExampleNot() { + var b Binary + + nb := Not(&b) + + fmt.Println(b.Healthy(context.Background())) + fmt.Println(nb.Healthy(context.Background())) + // Output: true + // false +} diff --git a/pkg/health/health_test.go b/pkg/health/health_test.go index 30cea23..ad51ebe 100644 --- a/pkg/health/health_test.go +++ b/pkg/health/health_test.go @@ -6,128 +6,142 @@ package health import ( - "net/http" - "net/http/httptest" + "context" "testing" "github.com/stretchr/testify/assert" ) -func TestStarted_ServeHTTP(t *testing.T) { - t.Run("will return 200", func(t *testing.T) { - t.Run("if it has been started", func(t *testing.T) { - var s Started - s.Started() - - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "http://example.com", nil) - - s.ServeHTTP(w, req) - if !assert.Equal(t, http.StatusOK, w.Result().StatusCode) { - return - } +func TestBinary_Toggle(t *testing.T) { + t.Run("will make it unhealthy", func(t *testing.T) { + t.Run("if the current state is healthy", func(t *testing.T) { + var m Binary + m.Toggle() + assert.False(t, m.Healthy(context.Background())) }) }) - t.Run("will return 503", func(t *testing.T) { - t.Run("if it is the zero value", func(t *testing.T) { - var s Started - - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "http://example.com", nil) - - s.ServeHTTP(w, req) - if !assert.Equal(t, http.StatusServiceUnavailable, w.Result().StatusCode) { - return + t.Run("will make it healthy", func(t *testing.T) { + t.Run("if the current state is unhealthy", func(t *testing.T) { + m := Binary{ + unhealthy: true, } + m.Toggle() + assert.True(t, m.Healthy(context.Background())) }) }) } -func TestReadinessServeHTTP(t *testing.T) { - t.Run("will return 200", func(t *testing.T) { - t.Run("if it has been marked ready", func(t *testing.T) { - var s Readiness - s.Ready() +type healthyMetric bool - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "http://example.com", nil) +func (m healthyMetric) Healthy(_ context.Context) bool { + return bool(m) +} - s.ServeHTTP(w, req) - if !assert.Equal(t, http.StatusOK, w.Result().StatusCode) { - return - } - }) +func TestAndMetric_Healthy(t *testing.T) { + t.Run("will return true", func(t *testing.T) { + testCases := []struct { + Name string + Metrics []Metric + }{ + { + Name: "if there is a single healthy metric", + Metrics: []Metric{healthyMetric(true)}, + }, + { + Name: "if all metrics are healthy", + Metrics: []Metric{healthyMetric(true), healthyMetric(true)}, + }, + } + for _, testCase := range testCases { + t.Run(testCase.Name, func(t *testing.T) { + am := And(testCase.Metrics...) + assert.True(t, am.Healthy(context.Background())) + }) + } }) - t.Run("will return 503", func(t *testing.T) { - t.Run("if it is the zero value", func(t *testing.T) { - var s Readiness - - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "http://example.com", nil) - - s.ServeHTTP(w, req) - if !assert.Equal(t, http.StatusServiceUnavailable, w.Result().StatusCode) { - return - } - }) - - t.Run("if it has been marked not ready", func(t *testing.T) { - var s Readiness - s.NotReady() - - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "http://example.com", nil) - - s.ServeHTTP(w, req) - if !assert.Equal(t, http.StatusServiceUnavailable, w.Result().StatusCode) { - return - } - }) + t.Run("will return false", func(t *testing.T) { + testCases := []struct { + Name string + Metrics []Metric + }{ + { + Name: "if there is a single unhealthy metric", + Metrics: []Metric{healthyMetric(false)}, + }, + { + Name: "if all metrics are all unhealthy", + Metrics: []Metric{healthyMetric(false), healthyMetric(false)}, + }, + { + Name: "if all one of the metrics is unhealthy", + Metrics: []Metric{healthyMetric(true), healthyMetric(false)}, + }, + { + Name: "if all one of the metrics is unhealthy (symmetric)", + Metrics: []Metric{healthyMetric(false), healthyMetric(true)}, + }, + } + for _, testCase := range testCases { + t.Run(testCase.Name, func(t *testing.T) { + am := And(testCase.Metrics...) + assert.False(t, am.Healthy(context.Background())) + }) + } }) } -func TestLiveness_ServeHTTP(t *testing.T) { - t.Run("will return 200", func(t *testing.T) { - t.Run("if it has been marked alive", func(t *testing.T) { - var s Liveness - s.Alive() - - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "http://example.com", nil) - - s.ServeHTTP(w, req) - if !assert.Equal(t, http.StatusOK, w.Result().StatusCode) { - return - } - }) +func TestOrMetric_Healthy(t *testing.T) { + t.Run("will return true", func(t *testing.T) { + testCases := []struct { + Name string + Metrics []Metric + }{ + { + Name: "if there is a single healthy metric", + Metrics: []Metric{healthyMetric(true)}, + }, + { + Name: "if all metrics are healthy", + Metrics: []Metric{healthyMetric(true), healthyMetric(true)}, + }, + { + Name: "if all one of the metrics is unhealthy", + Metrics: []Metric{healthyMetric(true), healthyMetric(false)}, + }, + { + Name: "if all one of the metrics is unhealthy (symmetric)", + Metrics: []Metric{healthyMetric(false), healthyMetric(true)}, + }, + } + for _, testCase := range testCases { + t.Run(testCase.Name, func(t *testing.T) { + om := Or(testCase.Metrics...) + assert.True(t, om.Healthy(context.Background())) + }) + } }) - t.Run("will return 503", func(t *testing.T) { - t.Run("if it is the zero value", func(t *testing.T) { - var s Liveness - - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "http://example.com", nil) - - s.ServeHTTP(w, req) - if !assert.Equal(t, http.StatusServiceUnavailable, w.Result().StatusCode) { - return - } - }) - - t.Run("if it has been marked dead", func(t *testing.T) { - var s Liveness - s.Dead() - - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "http://example.com", nil) - - s.ServeHTTP(w, req) - if !assert.Equal(t, http.StatusServiceUnavailable, w.Result().StatusCode) { - return - } - }) + t.Run("will return false", func(t *testing.T) { + testCases := []struct { + Name string + Metrics []Metric + }{ + { + Name: "if there is a single unhealthy metric", + Metrics: []Metric{healthyMetric(false)}, + }, + { + Name: "if all metrics are all unhealthy", + Metrics: []Metric{healthyMetric(false), healthyMetric(false)}, + }, + } + for _, testCase := range testCases { + t.Run(testCase.Name, func(t *testing.T) { + om := Or(testCase.Metrics...) + assert.False(t, om.Healthy(context.Background())) + }) + } }) }