Controls¶
The Controls component provides a sophisticated service lifecycle management system for GTB applications. It enables centralized control of multiple concurrent services with shared communication channels for errors, signals, health monitoring, and control messages.
Overview¶
The Controls package is built around the Controllable interface and the Controller struct, providing a unified API for managing service lifecycles. The component adds several key benefits:
Centralized Service Management: Coordinate multiple services (HTTP servers, background workers, schedulers) from a single controller with consistent start/stop behavior.
Shared Communication Channels: All registered services share common channels for errors, OS signals, health monitoring, and control messages, enabling coordinated responses to system events.
Graceful Shutdown: Built-in support for graceful shutdown with proper cleanup ordering and timeout handling.
Health Monitoring: Integrated health check system that services can use to report their status and respond to health requests.
Concurrent Safety: Thread-safe service registration and lifecycle management with proper synchronization primitives.
Quick Start¶
Get started quickly with a simple HTTP server managed by the controls system:
package main
import (
"context"
"http"
"log/slog"
"os"
"github.com/phpboyscout/gtb/pkg/controls"
)
func createStartFunc(srv *http.Server) func() error {
return func() error {
err := srv.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
}
func createStopFunc(ctx context.Context, srv *http.Server) func() {
return func() {
if err := srv.Shutdown(ctx); err != nil {
// Log error but don't panic during shutdown
slog.Error("Server shutdown error", "error", err)
}
}
}
func main() {
// Setup context for graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Create logger
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
// Create controller
controller := controls.NewController(ctx,
controls.WithLogger(logger),
)
// Create HTTP server
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("Hello from controlled service!"))
})
srv := &http.Server{
Addr: ":8080",
Handler: mux,
}
// Register the HTTP server as a controlled service
controller.Register(
"http-server", // Service ID
createStartFunc(srv), // Start function
createStopFunc(ctx, srv), // Stop function
func() {}, // Status function (empty for this example)
)
// Start all registered services
controller.Start()
// Wait for services to complete (blocks until shutdown)
controller.Wait()
logger.Info("Application shutdown complete")
}
This example demonstrates:
- Creating a controller with proper context and logger
- Registering an HTTP server as a managed service
- Defining start and stop functions that handle the service lifecycle
- Using the controller to coordinate service startup and shutdown
The controller automatically handles OS signals (SIGINT, SIGTERM) and will gracefully shutdown the HTTP server when these signals are received.
Core Interface¶
The Controllable interface provides the primary API for service control:
type Controllable interface {
// Channel access
Messages() chan Message
Health() chan HealthMessage
Errors() chan error
Signals() chan os.Signal
// Channel configuration
SetErrorsChannel(errs chan error)
SetMessageChannel(control chan Message)
SetSignalsChannel(sigs chan os.Signal)
SetHealthChannel(health chan HealthMessage)
// Lifecycle management
Start()
Stop()
SetWaitGroup(wg *sync.WaitGroup)
// Context and state management
GetContext() context.Context
SetState(state State)
GetState() State
// State queries
IsRunning() bool
IsStopped() bool
IsStopping() bool
// Logging
SetLogger(logger *slog.Logger)
GetLogger() *slog.Logger
// Service registration
Register(id string, start StartFunc, stop StopFunc, status StatusFunc)
}
Controller Implementation¶
The Controller struct is the primary implementation of the Controllable interface. Engineers should use this concrete type rather than the interface directly, except for testing and dependency injection:
type Controller struct {
ctx context.Context
logger *slog.Logger
messages chan Message
health chan HealthMessage
errs chan error
signals chan os.Signal
wg *sync.WaitGroup
state State
stateMutex sync.Mutex
services Services
}
// Factory function with options
func NewController(ctx context.Context, opts ...ControllerOpt) *Controller
// Available options
func WithoutSignals() ControllerOpt
func WithLogger(logger *slog.Logger) ControllerOpt
Service Types and States¶
Service Definition¶
type Service struct {
Name string
Start StartFunc
Stop StopFunc
Status StatusFunc
}
// Function types for service lifecycle
type StartFunc func() error
type StopFunc func()
type StatusFunc func()
type ValidErrorFunc func(error) bool
Controller States¶
type State string
type Message string
const (
Unknown State = "unknown"
Running State = "running"
Stopping State = "stopping"
Stopped State = "stopped"
)
const (
Stop Message = "stop"
Status Message = "status"
)
Health Monitoring¶
type HealthMessage struct {
Host string `json:"host"`
Port int `json:"port"`
Status int `json:"status"`
Message string `json:"message"`
}
Basic Usage¶
Creating a Controller¶
import (
"context"
"log/slog"
"github.com/phpboyscout/gtb/pkg/controls"
)
func setupController(ctx context.Context, logger *slog.Logger) *controls.Controller {
controller := controls.NewController(ctx,
controls.WithLogger(logger),
)
return controller
}
Registering Services¶
func registerHTTPServer(controller *controls.Controller, props *props.Props) {
// Create HTTP server
mux := http.NewServeMux()
mux.HandleFunc("/health", healthHandler)
server := &http.Server{
Addr: props.Config.GetString("server.addr"),
Handler: mux,
}
// Define service functions
startFunc := func() error {
props.Logger.Info("Starting HTTP server", "addr", server.Addr)
err := server.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
return errors.WrapPrefix(err, "HTTP server failed", 0)
}
return nil
}
stopFunc := func() {
props.Logger.Info("Stopping HTTP server")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
props.Logger.Error("HTTP server shutdown error", "error", err)
}
}
statusFunc := func() {
// Report service status to health channel
controller.Health() <- controls.HealthMessage{
Host: "localhost",
Port: 8080,
Status: 200,
Message: "HTTP server healthy",
}
}
// Register the service
controller.Register("http-server", startFunc, stopFunc, statusFunc)
}
Background Worker Service¶
func registerBackgroundWorker(controller *controls.Controller, props *props.Props) {
workerCtx, workerCancel := context.WithCancel(controller.GetContext())
startFunc := func() error {
props.Logger.Info("Starting background worker")
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-workerCtx.Done():
props.Logger.Info("Background worker shutting down")
return
case <-ticker.C:
// Perform background work
err := doBackgroundWork(props)
if err != nil {
controller.Errors() <- errors.WrapPrefix(err, "background work failed", 0)
}
}
}
}()
return nil
}
stopFunc := func() {
props.Logger.Info("Stopping background worker")
workerCancel()
}
statusFunc := func() {
controller.Health() <- controls.HealthMessage{
Host: "localhost",
Port: 0,
Status: 200,
Message: "Background worker healthy",
}
}
controller.Register("background-worker", startFunc, stopFunc, statusFunc)
}
Advanced Usage¶
Complete Application Setup¶
func main() {
// Setup context and cancellation
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Initialize Props
props, err := setupProps()
if err != nil {
log.Fatal("Failed to setup props:", err)
}
// Create controller
controller := controls.NewController(ctx,
controls.WithLogger(props.Logger),
)
// Register services
registerHTTPServer(controller, props)
registerBackgroundWorker(controller, props)
registerDatabaseService(controller, props)
// Setup error handling
go handleErrors(controller, props)
// Setup health monitoring
go handleHealthChecks(controller, props)
// Setup signal handling
go handleSignals(controller, props, cancel)
// Start all services
controller.Start()
// Wait for completion
controller.Wait()
props.Logger.Info("Application shutdown complete")
}
Error Handling¶
func handleErrors(controller *controls.Controller, props *props.Props) {
for {
select {
case <-controller.GetContext().Done():
return
case err := <-controller.Errors():
props.Logger.Error("Service error received", "error", err)
// Implement error handling strategy
if isCriticalError(err) {
props.Logger.Error("Critical error detected, initiating shutdown")
controller.Stop()
return
}
// Log non-critical errors but continue
props.Logger.Warn("Non-critical error, continuing operation", "error", err)
}
}
}
func isCriticalError(err error) bool {
// Define what constitutes a critical error
criticalPatterns := []string{
"database connection lost",
"authentication service unavailable",
"configuration validation failed",
}
errStr := err.Error()
for _, pattern := range criticalPatterns {
if strings.Contains(errStr, pattern) {
return true
}
}
return false
}
Health Monitoring¶
func handleHealthChecks(controller *controls.Controller, props *props.Props) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-controller.GetContext().Done():
return
case <-ticker.C:
// Request status from all services
controller.Messages() <- controls.Status
case health := <-controller.Health():
props.Logger.Info("Health check received",
"host", health.Host,
"port", health.Port,
"status", health.Status,
"message", health.Message)
// Store health information or forward to monitoring system
if health.Status >= 400 {
props.Logger.Warn("Service reporting unhealthy status",
"status", health.Status,
"message", health.Message)
}
}
}
}
Signal Handling¶
func handleSignals(controller *controls.Controller, props *props.Props, cancel context.CancelFunc) {
for {
select {
case <-controller.GetContext().Done():
return
case sig := <-controller.Signals():
props.Logger.Info("Received signal", "signal", sig)
switch sig {
case syscall.SIGINT, syscall.SIGTERM:
props.Logger.Info("Initiating graceful shutdown")
controller.Stop()
cancel()
return
case syscall.SIGUSR1:
// Custom signal handling - request status
props.Logger.Info("Status requested via signal")
controller.Messages() <- controls.Status
}
}
}
}
Testing¶
Using Mock Controllers¶
The GTB library includes auto-generated mocks for testing:
import (
"testing"
"github.com/phpboyscout/gtb/mocks/pkg/controls"
"github.com/stretchr/testify/assert"
)
func TestServiceRegistration(t *testing.T) {
mockController := controls.NewMockControllable(t)
// Set up expectations
mockController.EXPECT().Register("test-service", mock.Anything, mock.Anything, mock.Anything).Return()
mockController.EXPECT().Start().Return()
// Test service registration
service := NewTestService(mockController)
service.Initialize()
// Verify expectations are met automatically
}
Testing Service Functions¶
func TestHTTPServerLifecycle(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
controller := controls.NewController(ctx, controls.WithLogger(logger))
// Create test HTTP server
server := &http.Server{
Addr: ":0", // Use random port for testing
Handler: http.NewServeMux(),
}
started := false
stopped := false
startFunc := func() error {
started = true
// Simulate server start without actually binding
return nil
}
stopFunc := func() {
stopped = true
}
statusFunc := func() {
controller.Health() <- controls.HealthMessage{
Status: 200,
Message: "test server healthy",
}
}
// Register and test
controller.Register("test-server", startFunc, stopFunc, statusFunc)
// Test start
controller.Start()
assert.True(t, started)
// Test status
go func() {
controller.Messages() <- controls.Status
}()
select {
case health := <-controller.Health():
assert.Equal(t, 200, health.Status)
assert.Equal(t, "test server healthy", health.Message)
case <-time.After(1 * time.Second):
t.Fatal("Status response timeout")
}
// Test stop
controller.Stop()
assert.True(t, stopped)
}
Controllable Interface (For Testing and Mocking)¶
The Controllable interface is primarily used for testing and when working with provided mocks. In production code, use the concrete Controller type:
type Controllable interface {
Messages() chan Message
Health() chan HealthMessage
Errors() chan error
Signals() chan os.Signal
SetErrorsChannel(errs chan error)
SetMessageChannel(control chan Message)
SetSignalsChannel(sigs chan os.Signal)
SetHealthChannel(health chan HealthMessage)
SetWaitGroup(wg *sync.WaitGroup)
Start()
Stop()
GetContext() context.Context
SetState(state State)
GetState() State
SetLogger(logger *slog.Logger)
GetLogger() *slog.Logger
IsRunning() bool
IsStopped() bool
IsStopping() bool
Register(id string, start StartFunc, stop StopFunc, status StatusFunc)
}
Built-in Server Controls¶
GTB provides pre-configured server controls for HTTP and gRPC that integrate seamlessly with the controls lifecycle management.
HTTP Server Control¶
The pkg/controls/http package provides a standard HTTP/TLS server that follows best practices for timeouts and security.
Functions¶
NewServer(ctx context.Context, cfg config.Containable, handler http.Handler) (*http.Server, error): Returns a pre-configured*http.Serverwith production-ready timeouts (Read: 5s, Write: 10s, Idle: 120s) and secure TLS settings.Start(cfg config.Containable, logger *log.Logger, srv *http.Server) controls.StartFunc: Returns a start function that handles both HTTP and HTTPS based on configuration.Stop(logger *log.Logger, srv *http.Server) controls.StopFunc: Returns a stop function that performs a graceful shutdown.
Configuration¶
Expected configuration keys:
| Key | Type | Description |
|---|---|---|
server.port |
int |
The port to listen on. |
server.tls.enabled |
bool |
Whether to enable TLS. |
server.tls.cert |
string |
Path to the TLS certificate file. |
server.tls.key |
string |
Path to the TLS key file. |
Usage Example¶
import (
"github.com/phpboyscout/gtb/pkg/controls"
"github.com/phpboyscout/gtb/pkg/controls/http"
)
// In your application setup
srv, _ := http.NewServer(ctx, props.Config, myHandler)
controller.Register(
"http-api",
http.Start(props.Config, props.Logger, srv),
http.Stop(props.Logger, srv),
http.Status,
)
gRPC Server Control¶
The pkg/controls/grpc package provides a standard gRPC server with reflection enabled by default.
Functions¶
NewServer(cfg config.Containable, opt ...grpc.ServerOption) (*grpc.Server, error): Returns a new*grpc.Serverwith reflection registered.Start(cfg config.Containable, logger *log.Logger, srv *grpc.Server) controls.StartFunc: Returns a start function that listens on the configured port.Stop(logger *log.Logger, srv *grpc.Server) controls.StopFunc: Returns a stop function that performs aGracefulStop.
Configuration¶
Expected configuration keys:
| Key | Type | Description |
|---|---|---|
server.port |
int |
The port to listen on. |
Usage Example¶
import (
"github.com/phpboyscout/gtb/pkg/controls"
"github.com/phpboyscout/gtb/pkg/controls/grpc"
)
// In your application setup
srv, _ := grpc.NewServer(props.Config)
// Register your gRPC services here: pb.Register*Server(srv, myService)
controller.Register(
"grpc-api",
grpc.Start(props.Config, props.Logger, srv),
grpc.Stop(props.Logger, srv),
grpc.Status,
)
Best Practices¶
1. Use Concrete Types in Production¶
- Use
*controls.Controllerfor production service management - Use
controls.Controllableinterface for testing and dependency injection - Reserve the interface for mocking and testing scenarios
2. Service Design Patterns¶
// Recommended: Services should respect the controller's context
func createDatabaseService(controller *controls.Controller) (StartFunc, StopFunc, StatusFunc) {
var db *sql.DB
start := func() error {
var err error
db, err = sql.Open("postgres", connectionString)
if err != nil {
return errors.WrapPrefix(err, "failed to open database", 0)
}
// Test the connection
ctx, cancel := context.WithTimeout(controller.GetContext(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
return errors.WrapPrefix(err, "database ping failed", 0)
}
return nil
}
stop := func() {
if db != nil {
db.Close()
}
}
status := func() {
if db == nil {
controller.Health() <- controls.HealthMessage{
Status: 503,
Message: "Database not initialized",
}
return
}
ctx, cancel := context.WithTimeout(controller.GetContext(), 2*time.Second)
defer cancel()
err := db.PingContext(ctx)
if err != nil {
controller.Health() <- controls.HealthMessage{
Status: 503,
Message: fmt.Sprintf("Database unhealthy: %v", err),
}
} else {
controller.Health() <- controls.HealthMessage{
Status: 200,
Message: "Database healthy",
}
}
}
return start, stop, status
}
3. Error Handling Strategy¶
- Use the shared error channel for all service errors
- Implement error categorization (critical vs non-critical)
- Consider implementing retry logic for transient errors
- Always wrap errors with context using
cockroachdb/errors
4. Graceful Shutdown¶
// Implement proper timeout handling
func createGracefulService(timeout time.Duration) (StartFunc, StopFunc) {
var cancel context.CancelFunc
start := func() error {
ctx, c := context.WithCancel(context.Background())
cancel = c
go func() {
// Service loop with context cancellation support
for {
select {
case <-ctx.Done():
return
default:
// Do work
time.Sleep(100 * time.Millisecond)
}
}
}()
return nil
}
stop := func() {
if cancel != nil {
cancel()
}
// Wait for graceful shutdown with timeout
time.Sleep(timeout)
}
return start, stop
}
5. Health Check Implementation¶
- Implement meaningful health checks that verify actual service state
- Use appropriate HTTP status codes in health messages
- Include relevant diagnostic information in health messages
- Respond to status requests promptly
6. Channel Management¶
- Never close channels managed by the controller
- Use select statements with context cancellation
- Implement proper timeout handling for channel operations
Integration with GTB¶
The controls component integrates seamlessly with other GTB components:
// In your main application
func main() {
// Setup Props
props, err := setupProps()
if err != nil {
log.Fatal(err)
}
// Create controller with shared logger
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
controller := controls.NewController(ctx,
controls.WithLogger(slog.New(props.Logger)),
)
// Services can access shared Props
registerApplicationServices(controller, props)
// Start and manage lifecycle
controller.Start()
controller.Wait()
}
This controls component provides the foundation for robust service lifecycle management in GTB applications, enabling coordinated startup, shutdown, and monitoring of multiple concurrent services with shared communication channels and proper error handling.