|
| 1 | +<p align="center"> |
| 2 | + <a href="https://github.com/rluders/lane/actions/workflows/ci.yml"> |
| 3 | + <img src="https://github.com/rluders/lane/actions/workflows/ci.yml/badge.svg" alt="CI"> |
| 4 | + </a> |
| 5 | + <a href="https://go.dev/doc/devel/release"> |
| 6 | + <img src="https://img.shields.io/badge/go-1.26+-00ADD8?logo=go&logoColor=white" alt="Go 1.26+"> |
| 7 | + </a> |
| 8 | + <a href="https://pkg.go.dev/github.com/rluders/lane"> |
| 9 | + <img src="https://pkg.go.dev/badge/github.com/rluders/lane.svg" alt="Go Reference"> |
| 10 | + </a> |
| 11 | + <a href="https://goreportcard.com/report/github.com/rluders/lane"> |
| 12 | + <img src="https://goreportcard.com/badge/github.com/rluders/lane" alt="Go Report Card"> |
| 13 | + </a> |
| 14 | + <a href="https://github.com/rluders/lane/releases"> |
| 15 | + <img src="https://img.shields.io/github/v/release/rluders/lane?sort=semver" alt="GitHub release"> |
| 16 | + </a> |
| 17 | + <a href="https://codecov.io/gh/rluders/lane"> |
| 18 | + <img src="https://codecov.io/gh/rluders/lane/branch/main/graph/badge.svg" alt="codecov"> |
| 19 | + </a> |
| 20 | + <a href="LICENSE"> |
| 21 | + <img src="https://img.shields.io/github/license/rluders/lane" alt="License"> |
| 22 | + </a> |
| 23 | + <img src="https://img.shields.io/badge/dependencies-1-blue" alt="1 dependency"> |
| 24 | +</p> |
| 25 | + |
| 26 | +`lane` is a lightweight Go runtime that coordinates the lifecycle of service Runners. |
| 27 | + |
| 28 | +## 🤔 What is lane? |
| 29 | + |
| 30 | +`lane` manages the startup, health, and graceful shutdown of one or more service components — HTTP servers, background workers, schedulers — under a unified context. Register your runners, call `Run`, and lane handles the rest. |
| 31 | + |
| 32 | +## 🎯 Why use it? |
| 33 | + |
| 34 | +Wiring SIGINT/SIGTERM handling, concurrent startup, health probes, and ordered shutdown correctly is tedious boilerplate. `lane` does it once, predictably, so services stay focused on their own logic. |
| 35 | + |
| 36 | +## 🚫 What it does NOT do |
| 37 | + |
| 38 | +- It is not a dependency injection framework |
| 39 | +- It does not wire, store, or own your service dependencies |
| 40 | +- It does not prescribe configuration, logging format, or database access patterns |
| 41 | +- It does not define application structure beyond the `Runner` interface |
| 42 | + |
| 43 | +Dependency wiring is the caller's responsibility. |
| 44 | + |
| 45 | +## ⚡ Quick Example |
| 46 | + |
| 47 | +```go |
| 48 | +package main |
| 49 | + |
| 50 | +import ( |
| 51 | + "context" |
| 52 | + "log/slog" |
| 53 | + "net/http" |
| 54 | + "os" |
| 55 | + "time" |
| 56 | + |
| 57 | + "github.com/rluders/lane" |
| 58 | + "github.com/rluders/lane/runners" |
| 59 | +) |
| 60 | + |
| 61 | +func main() { |
| 62 | + lane.RunHealthCheck(":8080") |
| 63 | + |
| 64 | + log := slog.New(slog.NewTextHandler(os.Stderr, nil)) |
| 65 | + |
| 66 | + mux := http.NewServeMux() |
| 67 | + |
| 68 | + l := lane.New(log, lane.WithShutdownTimeout(10*time.Second)) |
| 69 | + |
| 70 | + mux.Handle("GET /ready", lane.ReadinessHandler(l.Health())) |
| 71 | + mux.Handle("GET /live", lane.LivenessHandler()) |
| 72 | + |
| 73 | + server := &http.Server{Addr: ":8080", Handler: mux} |
| 74 | + l.AddRunner(runners.NewHTTPRunner("api", server, log)) |
| 75 | + |
| 76 | + if err := l.Run(context.Background()); err != nil { |
| 77 | + log.Error("lane error", "error", err) |
| 78 | + os.Exit(1) |
| 79 | + } |
| 80 | +} |
| 81 | +``` |
| 82 | + |
| 83 | +## 🔄 Lifecycle |
| 84 | + |
| 85 | +```mermaid |
| 86 | +flowchart TD |
| 87 | + A[lane.New] --> B[AddRunner x N] |
| 88 | + B --> C[l.Run] |
| 89 | + C --> D[Start all runners concurrently] |
| 90 | + D --> E[health.SetReady true] |
| 91 | + E --> F{waiting} |
| 92 | + F -- SIGINT/SIGTERM --> G[Signal received] |
| 93 | + F -- runner error --> H[Context cancelled] |
| 94 | + G --> I[health.SetReady false] |
| 95 | + H --> I |
| 96 | + I --> J[Stop runners in LIFO order] |
| 97 | + J --> K[Shutdown timeout context] |
| 98 | + K --> L[Return first error or nil] |
| 99 | +``` |
| 100 | + |
| 101 | +Runners are started concurrently. Shutdown is sequential in **reverse registration order** (LIFO), so dependents stop before the services they depend on. |
| 102 | + |
| 103 | +## 🧩 Runner Interface |
| 104 | + |
| 105 | +Any component that can start and stop is a Runner: |
| 106 | + |
| 107 | +```go |
| 108 | +type Runner interface { |
| 109 | + Name() string |
| 110 | + Start(ctx context.Context) error |
| 111 | + Stop(ctx context.Context) error |
| 112 | +} |
| 113 | +``` |
| 114 | + |
| 115 | +**Contract:** |
| 116 | +- `Start` **must block** until the runner stops or fails |
| 117 | +- `Stop` initiates graceful shutdown and **must respect the context deadline** |
| 118 | +- If `Start` returns a non-nil error, lane cancels all other runners |
| 119 | + |
| 120 | +## 📦 Built-in Runners |
| 121 | + |
| 122 | +All implementations live in the `runners/` sub-package. |
| 123 | + |
| 124 | +| Runner | Constructor | Description | |
| 125 | +|--------|-------------|-------------| |
| 126 | +| HTTP | `runners.NewHTTPRunner(name, server, log)` | Wraps `*http.Server`. Calls `Shutdown` on stop. | |
| 127 | +| HTTPS | `runners.NewHTTPSRunner(name, server, certFile, keyFile, log)` | Same as HTTPRunner with TLS. | |
| 128 | +| Worker | `runners.NewWorkerRunner(name, fn, log)` | Runs a `WorkFn` in a loop until ctx is cancelled. | |
| 129 | +| Scheduler | `runners.NewSchedulerRunner(name, interval, fn, log)` | Runs a `JobFn` on a fixed interval. Missed ticks are dropped. | |
| 130 | + |
| 131 | +### Worker example |
| 132 | + |
| 133 | +```go |
| 134 | +worker := runners.NewWorkerRunner("processor", func(ctx context.Context) error { |
| 135 | + return processNextMessage(ctx) |
| 136 | +}, log) |
| 137 | +l.AddRunner(worker) |
| 138 | +``` |
| 139 | + |
| 140 | +### Scheduler example |
| 141 | + |
| 142 | +```go |
| 143 | +sched := runners.NewSchedulerRunner("cleanup", 5*time.Minute, func(ctx context.Context) error { |
| 144 | + return purgeExpiredSessions(ctx) |
| 145 | +}, log) |
| 146 | +l.AddRunner(sched) |
| 147 | +``` |
| 148 | + |
| 149 | +## 🏥 Health Checks |
| 150 | + |
| 151 | +`HealthState` tracks readiness. Lane sets it to ready after all runners start, and back to not-ready at the beginning of shutdown. |
| 152 | + |
| 153 | +```go |
| 154 | +mux.Handle("GET /ready", lane.ReadinessHandler(l.Health(), db.PingContext)) |
| 155 | +mux.Handle("GET /live", lane.LivenessHandler()) |
| 156 | +``` |
| 157 | + |
| 158 | +`ReadinessHandler` accepts zero or more probe functions of type `func(ctx context.Context) error`. All probes must pass for the endpoint to return 200. If any probe fails, the endpoint returns 503. |
| 159 | + |
| 160 | +`LivenessHandler` always returns 200 — a running process is a live process. |
| 161 | + |
| 162 | +### Container health probe |
| 163 | + |
| 164 | +Call `RunHealthCheck` at the very top of `main()` to support Docker `HEALTHCHECK CMD`-based probes: |
| 165 | + |
| 166 | +```go |
| 167 | +func main() { |
| 168 | + lane.RunHealthCheck(":8080") |
| 169 | + // ... rest of main |
| 170 | +} |
| 171 | +``` |
| 172 | + |
| 173 | +When invoked as `myservice healthcheck`, the process exits 0 (healthy) or 1 (unhealthy) immediately without starting the service. |
| 174 | + |
| 175 | +## 🛡️ Recovery |
| 176 | + |
| 177 | +### Goroutine recovery |
| 178 | + |
| 179 | +`lane.Go` runs a goroutine with structured panic recovery. On panic it logs the stack trace and calls the provided cancel function to propagate the failure up to lane. |
| 180 | + |
| 181 | +```go |
| 182 | +lane.Go(ctx, log, "worker-name", cancel, func(ctx context.Context) { |
| 183 | + // critical goroutine — a panic here cancels the application context |
| 184 | +}) |
| 185 | +``` |
| 186 | + |
| 187 | +### HTTP handler recovery |
| 188 | + |
| 189 | +`RecoverMiddleware` wraps HTTP handlers with panic recovery. On panic it logs structured context (method, path, stack trace) and returns HTTP 500. The service continues — a single handler panic is recoverable. |
| 190 | + |
| 191 | +```go |
| 192 | +handler = lane.RecoverMiddleware(log)(handler) |
| 193 | +``` |
| 194 | + |
| 195 | +## ⏱️ Shutdown Timeout |
| 196 | + |
| 197 | +Default shutdown timeout is **30 seconds**. Override with `WithShutdownTimeout`: |
| 198 | + |
| 199 | +```go |
| 200 | +l := lane.New(log, lane.WithShutdownTimeout(10*time.Second)) |
| 201 | +``` |
| 202 | + |
| 203 | +Each runner's `Stop` is called with a context that respects this deadline. If a runner does not stop within the timeout, shutdown proceeds without it. |
| 204 | + |
| 205 | +## 🗓️ When to use lane |
| 206 | + |
| 207 | +Use it when you want: |
| 208 | + |
| 209 | +- ✅ Go services with multiple concurrent components (API server + worker + scheduler) |
| 210 | +- ✅ Predictable SIGTERM handling and readiness probes in containers |
| 211 | +- ✅ Correct lifecycle management without pulling in a full service framework |
| 212 | + |
| 213 | +Avoid it if you need: |
| 214 | + |
| 215 | +- ❌ A dependency injection container |
| 216 | +- ❌ A full application framework with routing conventions |
| 217 | +- ❌ Managed configuration, observability, or deployment tooling |
| 218 | + |
| 219 | +## 📄 License |
| 220 | + |
| 221 | +MIT — see [LICENSE](LICENSE). |
0 commit comments