diff --git a/README.md b/README.md index 7d9f5f7..155d0d8 100644 --- a/README.md +++ b/README.md @@ -84,12 +84,12 @@ A curated collection of idiomatic design & application patterns for Go language. | Pattern | Description | Status | |:-------:|:----------- |:------:| -| [Bulkheads](/stability/bulkhead.md) | Enforces a principle of failure containment (i.e. prevents cascading failures) | ✘ | +| [Bulkheads](/stability/bulkhead.md) | Enforces a principle of failure containment (i.e. prevents cascading failures) | ✔ | | [Circuit-Breaker](/stability/circuit-breaker.md) | Stops the flow of the requests when requests are likely to fail | ✔ | -| [Deadline](/stability/deadline.md) | Allows clients to stop waiting for a response once the probability of response becomes low (e.g. after waiting 10 seconds for a page refresh) | ✘ | -| [Fail-Fast](/stability/fail_fast.md) | Checks the availability of required resources at the start of a request and fails if the requirements are not satisfied | ✘ | -| [Handshaking](/stability/handshaking.md) | Asks a component if it can take any more load, if it can't, the request is declined | ✘ | -| [Steady-State](/stability/steady_state.md) | For every service that accumulates a resource, some other service must recycle that resource | ✘ | +| [Deadline](/stability/deadline.md) | Allows clients to stop waiting for a response once the probability of response becomes low (e.g. after waiting 10 seconds for a page refresh) | ✔ | +| [Fail-Fast](/stability/fail_fast.md) | Checks the availability of required resources at the start of a request and fails if the requirements are not satisfied | ✔ | +| [Handshaking](/stability/handshaking.md) | Asks a component if it can take any more load, if it can't, the request is declined | ✔ | +| [Steady-State](/stability/steady_state.md) | For every service that accumulates a resource, some other service must recycle that resource | ✔ | ## Profiling Patterns diff --git a/stability/bulkhead.md b/stability/bulkhead.md new file mode 100644 index 0000000..42d638f --- /dev/null +++ b/stability/bulkhead.md @@ -0,0 +1,83 @@ +# Bulkhead Pattern + +The bulkhead pattern is inspired by the sectioned partitions (bulkheads) of a +ship's hull. If one section is breached, only that section floods — the rest of +the ship stays afloat. In software, the pattern isolates elements of an +application into pools so that if one fails, the others continue to function. + +By partitioning resource access (e.g. connection pools, goroutine pools, or +semaphores), the bulkhead pattern prevents a single failing component from +consuming all resources and cascading into a system-wide outage. + +## Implementation + +Below is a `Bulkhead` that limits concurrent access to a downstream service +using a buffered channel as a semaphore. + +```go +package bulkhead + +import ( + "errors" + "time" +) + +var ( + ErrBulkheadFull = errors.New("bulkhead capacity full") +) + +// Bulkhead limits the number of concurrent calls to a function. +type Bulkhead struct { + sem chan struct{} + timeout time.Duration +} + +// New creates a Bulkhead with the given maximum concurrent capacity and a +// timeout for acquiring a slot. +func New(capacity int, timeout time.Duration) *Bulkhead { + return &Bulkhead{ + sem: make(chan struct{}, capacity), + timeout: timeout, + } +} + +// Execute runs fn if a slot is available within the configured timeout. +// If the bulkhead is full it returns ErrBulkheadFull without executing fn. +func (b *Bulkhead) Execute(fn func() error) error { + select { + case b.sem <- struct{}{}: + defer func() { <-b.sem }() + return fn() + case <-time.After(b.timeout): + return ErrBulkheadFull + } +} +``` + +## Usage + +```go +orderBulkhead := bulkhead.New(10, 1*time.Second) +paymentBulkhead := bulkhead.New(5, 1*time.Second) + +// The order service is isolated from the payment service. +// If payments exhaust their 5 slots, orders can still proceed +// with their independent pool of 10. +err := orderBulkhead.Execute(func() error { + return orderService.Place(order) +}) + +err = paymentBulkhead.Execute(func() error { + return paymentService.Charge(order) +}) + +if errors.Is(err, bulkhead.ErrBulkheadFull) { + log.Println("service is at capacity, try again later") +} +``` + +## Rules of Thumb + +- Size each bulkhead based on the downstream service's capacity and expected latency. +- Combine with the circuit breaker pattern: a bulkhead limits concurrency while a circuit breaker stops calls to an already-failing service. +- Monitor bulkhead rejection rates — a consistently full bulkhead indicates the pool is undersized or the downstream is too slow. diff --git a/stability/deadline.md b/stability/deadline.md new file mode 100644 index 0000000..7c928f4 --- /dev/null +++ b/stability/deadline.md @@ -0,0 +1,74 @@ +# Deadline Pattern + +The deadline pattern allows a client to stop waiting for a response once a +specified amount of time has passed, at which point the probability of a +successful response becomes too low to be useful. This avoids tying up resources +indefinitely on slow or unresponsive operations. + +In Go, the `context` package provides first-class support for deadlines and +timeouts, making it the idiomatic way to implement this pattern. + +## Implementation + +```go +package deadline + +import ( + "context" + "time" +) + +// Work represents a unit of work that respects context cancellation. +type Work func(ctx context.Context) error + +// WithDeadline wraps a unit of work with a deadline. If the work does not +// complete before the deadline, the context is cancelled and an error is +// returned. +func WithDeadline(timeout time.Duration, work Work) Work { + return func(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + done := make(chan error, 1) + + go func() { + done <- work(ctx) + }() + + select { + case err := <-done: + return err + case <-ctx.Done(): + return ctx.Err() + } + } +} +``` + +## Usage + +```go +slowOperation := func(ctx context.Context) error { + select { + case <-time.After(5 * time.Second): + fmt.Println("operation completed") + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// Wrap the slow operation with a 2-second deadline. +wrapped := deadline.WithDeadline(2*time.Second, slowOperation) + +err := wrapped(context.Background()) +if err != nil { + fmt.Println(err) // context deadline exceeded +} +``` + +## Rules of Thumb + +- Always propagate the `context.Context` into downstream calls so that cancellation reaches every layer. +- Choose deadline values based on observed latency percentiles (e.g. p99) rather than arbitrary round numbers. +- Prefer `context.WithTimeout` for relative durations and `context.WithDeadline` for absolute wall-clock deadlines. diff --git a/stability/fail_fast.md b/stability/fail_fast.md new file mode 100644 index 0000000..0ac73c1 --- /dev/null +++ b/stability/fail_fast.md @@ -0,0 +1,90 @@ +# Fail-Fast Pattern + +The fail-fast pattern checks the availability of required resources at the start +of a request and fails immediately if the requirements are not satisfied. Rather +than performing expensive work only to discover a missing dependency halfway +through, the request is rejected early with a clear error. + +This reduces wasted computation, frees resources faster, and gives callers +immediate feedback so they can retry or fall back. + +## Implementation + +```go +package failfast + +import "errors" + +// Checker validates whether a required precondition is met. +type Checker func() error + +// Handler represents the actual business logic to execute. +type Handler func() error + +// FailFast verifies all preconditions before invoking the handler. +// If any check fails, it returns immediately without calling the handler. +func FailFast(handler Handler, checks ...Checker) error { + for _, check := range checks { + if err := check(); err != nil { + return err + } + } + + return handler() +} +``` + +A concrete example — a request handler that validates a database connection and +cache availability before processing: + +```go +package failfast + +import "errors" + +var ( + ErrDBUnavailable = errors.New("database is unavailable") + ErrCacheUnavailable = errors.New("cache is unavailable") +) + +func CheckDB(db *sql.DB) Checker { + return func() error { + if err := db.Ping(); err != nil { + return ErrDBUnavailable + } + return nil + } +} + +func CheckCache(cache CacheClient) Checker { + return func() error { + if !cache.IsAlive() { + return ErrCacheUnavailable + } + return nil + } +} +``` + +## Usage + +```go +err := failfast.FailFast( + func() error { + // Expensive business logic that requires both DB and cache. + return processOrder(order) + }, + failfast.CheckDB(db), + failfast.CheckCache(cache), +) + +if err != nil { + log.Printf("request rejected early: %v", err) +} +``` + +## Rules of Thumb + +- Only check preconditions that are cheap to verify (pings, health flags). The goal is to fail fast, not to add latency. +- Combine with the circuit breaker pattern: a circuit breaker remembers previous failures, while fail-fast verifies current readiness. +- Return specific error types so callers can distinguish between "not ready" and "processing failed." diff --git a/stability/handshaking.md b/stability/handshaking.md new file mode 100644 index 0000000..6f5b37a --- /dev/null +++ b/stability/handshaking.md @@ -0,0 +1,102 @@ +# Handshaking Pattern + +The handshaking pattern allows a component to ask another component whether it +can accept more load before sending actual work. If the target component signals +that it is at capacity, the request is declined without even attempting the +operation. This protects both the caller and the callee from being overwhelmed. + +Unlike the circuit breaker, which reacts to failures after they happen, +handshaking is a proactive, cooperative mechanism — the service itself +advertises its readiness. + +## Implementation + +```go +package handshaking + +import "errors" + +var ( + ErrServiceAtCapacity = errors.New("service is at capacity") +) + +// Service represents a downstream component that supports health negotiation. +type Service interface { + // IsReady reports whether the service can accept new work. + IsReady() bool + + // Do performs the actual work. + Do(req Request) (Response, error) +} + +// Request and Response are domain-specific types. +type Request struct { + Payload interface{} +} + +type Response struct { + Result interface{} +} + +// Call performs a handshake with the target service before sending the request. +// If the service is not ready, it returns ErrServiceAtCapacity immediately. +func Call(svc Service, req Request) (Response, error) { + if !svc.IsReady() { + return Response{}, ErrServiceAtCapacity + } + + return svc.Do(req) +} +``` + +A concrete service implementation using active connection tracking: + +```go +package handshaking + +import "sync/atomic" + +type TrackedService struct { + active int64 + capacity int64 + handler func(Request) (Response, error) +} + +func NewTrackedService(capacity int64, handler func(Request) (Response, error)) *TrackedService { + return &TrackedService{ + capacity: capacity, + handler: handler, + } +} + +func (s *TrackedService) IsReady() bool { + return atomic.LoadInt64(&s.active) < s.capacity +} + +func (s *TrackedService) Do(req Request) (Response, error) { + atomic.AddInt64(&s.active, 1) + defer atomic.AddInt64(&s.active, -1) + + return s.handler(req) +} +``` + +## Usage + +```go +svc := handshaking.NewTrackedService(100, func(req handshaking.Request) (handshaking.Response, error) { + result, err := processWork(req.Payload) + return handshaking.Response{Result: result}, err +}) + +resp, err := handshaking.Call(svc, handshaking.Request{Payload: data}) +if errors.Is(err, handshaking.ErrServiceAtCapacity) { + log.Println("service busy, back off and retry later") +} +``` + +## Rules of Thumb + +- The `IsReady` check must be cheap — it should read a counter or flag, not run diagnostics. +- Handshaking works best for in-process or sidecar communication. For remote services, consider a health-check endpoint that returns HTTP 503 when at capacity. +- Combine with retry and backoff logic on the caller side to handle transient capacity limits gracefully. diff --git a/stability/steady_state.md b/stability/steady_state.md new file mode 100644 index 0000000..d973061 --- /dev/null +++ b/stability/steady_state.md @@ -0,0 +1,129 @@ +# Steady-State Pattern + +The steady-state pattern states that for every service that accumulates a +resource, some other mechanism must recycle that resource. Without active +cleanup, unbounded growth of logs, caches, temporary files, or connections will +eventually exhaust the system and cause failures. + +The goal is to keep the system in a stable, predictable operating range without +human intervention. + +## Implementation + +Below is a generic `Purger` that periodically cleans up accumulated resources to +maintain a steady state. + +```go +package steadystate + +import ( + "log" + "time" +) + +// Resource represents an accumulating resource that can report its size and +// be purged. +type Resource interface { + // Size returns the current amount of accumulated resources. + Size() int64 + + // Purge removes resources that are older than the given threshold. + Purge(olderThan time.Duration) (purged int64, err error) +} + +// Purger periodically checks a resource and purges entries that exceed the +// maximum age, keeping the system in a steady state. +type Purger struct { + resource Resource + maxAge time.Duration + interval time.Duration + stop chan struct{} +} + +// NewPurger creates a purger that checks the resource at the given interval +// and removes entries older than maxAge. +func NewPurger(r Resource, maxAge, interval time.Duration) *Purger { + return &Purger{ + resource: r, + maxAge: maxAge, + interval: interval, + stop: make(chan struct{}), + } +} + +// Start begins the periodic purge loop in a background goroutine. +func (p *Purger) Start() { + ticker := time.NewTicker(p.interval) + + go func() { + for { + select { + case <-ticker.C: + before := p.resource.Size() + purged, err := p.resource.Purge(p.maxAge) + if err != nil { + log.Printf("purge error: %v", err) + continue + } + log.Printf("purged %d items (before: %d, after: %d)", + purged, before, before-purged) + case <-p.stop: + ticker.Stop() + return + } + } + }() +} + +// Stop terminates the purge loop. +func (p *Purger) Stop() { + close(p.stop) +} +``` + +## Usage + +```go +// LogDir implements the steadystate.Resource interface for a log directory. +type LogDir struct { + path string +} + +func (d *LogDir) Size() int64 { + entries, _ := os.ReadDir(d.path) + return int64(len(entries)) +} + +func (d *LogDir) Purge(olderThan time.Duration) (int64, error) { + entries, err := os.ReadDir(d.path) + if err != nil { + return 0, err + } + + var purged int64 + cutoff := time.Now().Add(-olderThan) + for _, e := range entries { + info, err := e.Info() + if err != nil { + continue + } + if info.ModTime().Before(cutoff) { + os.Remove(filepath.Join(d.path, e.Name())) + purged++ + } + } + return purged, nil +} + +// Purge log files older than 7 days, checking every hour. +purger := steadystate.NewPurger(&LogDir{path: "/var/log/myapp"}, 7*24*time.Hour, 1*time.Hour) +purger.Start() +defer purger.Stop() +``` + +## Rules of Thumb + +- Every accumulating resource (logs, temp files, cache entries, sessions) must have a corresponding cleanup mechanism. +- Prefer time-based purging over size-based purging — it is simpler and more predictable. +- Monitor the resource size over time. If it trends upward despite purging, the purge interval or threshold needs adjustment. +- Run purgers as background goroutines with graceful shutdown support to avoid data loss.