Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8f3176053 | ||
|
|
f4abb626c6 | ||
|
|
64c7e6b65d | ||
|
|
b9a5fa8bd8 | ||
|
|
ba9b29697a | ||
|
|
cb93d33b9c |
No files matched your search
@@ -15,17 +15,6 @@ on:
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
env:
|
||||
POSTGRES_PASSWORD: migrate-test
|
||||
POSTGRES_DB: migrate_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres -d migrate_test"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
@@ -37,9 +26,6 @@ jobs:
|
||||
run: test -z "$(gofmt -l .)"
|
||||
- run: go vet ./...
|
||||
- run: go test -race ./...
|
||||
env:
|
||||
CI: "true"
|
||||
MIGRATE_POSTGRES_DSN: postgres://postgres:migrate-test@postgres:5432/migrate_test?sslmode=disable
|
||||
- name: Require issue URLs in TODO comments
|
||||
run: |
|
||||
find . -type d \( -name .git -o -name node_modules -o -name vendor -o -name generated -o -name gen -o -name target -o -name dist -o -name build -o -name .svelte-kit -o -name .next \) -prune -o -type f \( -name '*.go' -o -name '*.ts' -o -name '*.tsx' -o -name '*.js' -o -name '*.svelte' -o -name '*.rs' -o -name '*.sql' -o -name '*.sh' -o -name '*.proto' -o -name '*.graphql' -o -name '*.yml' -o -name '*.yaml' -o -name '*.toml' \) -exec awk 'match($0, "((^|[^:])[/][/]|[/][*]|(^|[[:space:]])(#|--)|^[[:space:]]*([*]|<!--))") { comment=substr($0, RSTART + RLENGTH); todo=index(comment, "TO" "DO"); if (todo && substr(comment, todo + 4) !~ "https?:[/][/]") { print FILENAME ":" FNR ":" $0; bad=1 } } END { exit bad }' {} + # shortcut: line-based scan can flag comment-like strings and misses multiline issue links, use a parser when these need accurate coverage
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
# corestack go
|
||||
|
||||
Shared Go building blocks. The first one is `docs`, a checker for architecture decision records (ADRs) and implementation plans.
|
||||
|
||||
## migrate
|
||||
|
||||
[`migrate`](migrate/README.md) configures Goose providers for embedded SQL and Go
|
||||
migrations, with structured logging and Postgres startup locking.
|
||||
Shared Go building blocks. Includes `docs`, a checker for architecture decision records (ADRs) and implementation plans, and `lifecycle`, a service and worker coordinator.
|
||||
|
||||
## docs
|
||||
|
||||
@@ -25,6 +20,31 @@ docs check [--root DIR] # prints path:line: messa
|
||||
|
||||
The format is specified in [docs/docs-format.md](docs/docs-format.md); that document is the complete rule set. Exit status is `0` when every record is valid, `1` when problems are reported and `2` for usage errors.
|
||||
|
||||
## lifecycle
|
||||
|
||||
`go.auvem.com/corestack/lifecycle` sets up modules in registration order, runs
|
||||
services concurrently, and tears down successful setups in reverse order.
|
||||
Construct an `App` with a required `*slog.Logger`, register `Module` callbacks,
|
||||
and call `Run(ctx)` once. See the [two-service example](lifecycle/example_test.go).
|
||||
|
||||
Launch background work with `app.Go(ctx, name, fn)` using the callback's lifecycle
|
||||
context or a descendant, and handle admission errors. Run waits for every service
|
||||
and admitted worker; raw goroutines remain your responsibility. First failure or
|
||||
an injected signal cancels work before teardown. One shared shutdown deadline
|
||||
(default 30 seconds) covers hooks and callback completion; a second signal
|
||||
abandons shutdown. Callbacks must cooperate: abandonment cannot kill goroutines,
|
||||
and the caller owns process exit and any unreleased resources.
|
||||
|
||||
During App-initiated shutdown, callback errors containing only the callback's
|
||||
context cancellation are omitted. Mixed cancellation and independent failures
|
||||
are retained intact to preserve wrapper types, identity and custom matching;
|
||||
these errors may still match cancellation through `errors.Is`.
|
||||
|
||||
The caller owns signal subscription, SIGINT/SIGTERM filtering, a channel buffered
|
||||
for at least two signals, and subscription cleanup. Nil signals disable reception.
|
||||
There are no signal subscriptions, process exits, external dependencies or CLI
|
||||
integration in this package.
|
||||
|
||||
## Status
|
||||
|
||||
No support is guaranteed. Breaking changes, including to the format rules, may happen before 1.0 and are listed in the release notes.
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: "Supervise module lifecycle and managed workers within one shutdown budget"
|
||||
date: 2026-10-27
|
||||
status: accepted
|
||||
implementation: implemented
|
||||
summary: Order module startup and reverse cleanup while supervising services and background workers under one bounded shutdown.
|
||||
tags:
|
||||
- lifecycle
|
||||
supersedes: []
|
||||
superseded_by: []
|
||||
related:
|
||||
- /docs/adr/20261025T214616275Z-version-light-core-packages-in-the-root-module-and-nest-heavy-dependency-modules.md
|
||||
tracking:
|
||||
- https://gitea.auvem.com/corestack/go/issues/5
|
||||
implemented_by:
|
||||
- https://gitea.auvem.com/corestack/go/pulls/13
|
||||
---
|
||||
|
||||
# Supervise module lifecycle and managed workers within one shutdown budget
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
[Issue 5](https://gitea.auvem.com/corestack/go/issues/5) needs ordered startup and supervised concurrent services for Lark Relay's daemons and muxer. Its [follow-up](https://gitea.auvem.com/corestack/go/issues/5#issuecomment-7805) also requires explicit coordination of background workers: canceling service contexts alone cannot establish that the entire app has stopped.
|
||||
|
||||
The owner approved a standard-library-only root package, registration-order setup, reverse rollback/teardown, injected signals and logging, joined errors, and one configurable shutdown budget (default 30 seconds) covering teardown and worker completion. Cancellation precedes teardown, which may unblock workers; a second signal abandons shutdown. The owner accepted the API and ownership details after independent review, including the R1/R2/R3 clarifications. It is a clean-room design; no go-toolkit/app source is used.
|
||||
|
||||
## Considered Options
|
||||
|
||||
* Keep coordination in each application's main function: least library code, but duplicates signal, rollback and worker ownership rules across the initial consumers.
|
||||
* Provide only ordered hooks and service supervision: smaller package, but callers still need a second worker coordinator to satisfy the follow-up and share its shutdown deadline.
|
||||
* Provide ordered hooks plus a managed-worker launch method: one owner for cancellation, registration closure, errors and the shutdown budget, without a dependency graph or framework.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Choose the third option in `go.auvem.com/corestack/lifecycle`, sharing root-module versions under the accepted module-boundary decision. No CLI integration, dependency DAG, external dependency, restart policy or UI is included.
|
||||
|
||||
The public surface is:
|
||||
|
||||
```go
|
||||
type Module struct {
|
||||
Name string
|
||||
Setup func(context.Context) error
|
||||
Teardown func(context.Context) error
|
||||
Run func(context.Context) error // optional
|
||||
}
|
||||
type Config struct {
|
||||
Logger *slog.Logger
|
||||
Signals <-chan os.Signal
|
||||
ShutdownTimeout time.Duration
|
||||
}
|
||||
type App struct { /* private state; no stored context */ }
|
||||
func New(config Config) (*App, error)
|
||||
func (a *App) Register(module Module) error
|
||||
func (a *App) Go(ctx context.Context, name string, fn func(context.Context) error) error
|
||||
func (a *App) Run(ctx context.Context) error
|
||||
```
|
||||
|
||||
The accepted signatures have the following edge semantics:
|
||||
|
||||
* `New` requires a non-nil logger; zero timeout selects 30 seconds and negative durations fail. `Register` requires a unique nonempty module name and non-nil setup/teardown. Registration closes when `Run` begins; an App runs once. Invalid or late calls return errors without launching work. Configuration is captured by `New` and modules by `Register`, with no global state or initialization side effects.
|
||||
* `Run` sets up modules sequentially in registration order using a shared child of its parent context. Only after every setup succeeds does it launch the optional service callbacks concurrently. Dependencies are expressed by registration order and ordinary Go wiring, not lookup or dependency injection APIs. Service scheduling order is unspecified.
|
||||
* `Go` is the explicit tooling for background work. A setup or service callback can capture the App and call `Go`; the supplied context must be that callback's lifecycle context or a descendant. Context ancestry is a caller precondition that App cannot generally enforce; detached contexts forfeit graceful cancellation. It launches `fn(ctx)` immediately, tracks completion and errors, and returns once admitted; admission is atomic with shutdown closure. It is available only during setup/running, accepts a nonempty diagnostic name and non-nil function, and is safe for concurrent callers. Worker names need not be unique. Callers must handle admission errors. Raw goroutines remain the caller's responsibility and cannot be included in the completion guarantee.
|
||||
* Once startup completes, normal completion means every service and admitted worker has returned nil; no services/workers means immediate normal shutdown. A nil return from just one service/worker does not stop the others. Normal completion atomically closes worker admission so a racing launch is either tracked or rejected. Before startup completes, temporarily having no workers does not end the run.
|
||||
* The first setup/service/worker failure, parent cancellation, or received SIGINT/SIGTERM starts shutdown and closes worker admission. Cancel the shared context first, then tear down successfully completed setups in reverse order while services/workers unwind concurrently. Do not wait for workers before teardown: closing a listener or queue may be what releases them. A failing setup owns cleanup of its partial acquisition and is not torn down by App.
|
||||
* One fresh shutdown context retains parent values but is detached from parent cancellation and has the configured deadline, measured from the shutdown trigger. Reverse teardown hooks run sequentially with that context; all admitted services/workers share the same remaining completion budget. An in-flight setup is canceled and awaited only within this budget before reverse rollback, avoiding concurrent setup and teardown. Its successful return before abandonment adds it to rollback; failure does not. No further setup starts after shutdown begins.
|
||||
* The deadline or a second signal cancels the shutdown context and returns without starting remaining teardown hooks or waiting further for callbacks. App must observe setup/teardown results asynchronously so an uncooperative hook cannot block its return. App will not later call teardown for an abandoned setup, even if it later succeeds; its acquisitions may leak until caller-owned process exit. A cancellation-aware setup must clean up partial acquisitions and return an error when it observes cancellation. Completion reporting must not block after App returns. Go cannot forcibly kill uncooperative goroutines: the bound covers App's coordination and return, not guaranteed resource release or goroutine termination. Callbacks must honor cancellation, and the caller owns process exit and must not reuse abandoned resources. Injected logging must not block shutdown.
|
||||
* The caller owns OS signal subscription, filtering to SIGINT/SIGTERM, buffering (at least two), and subscription cleanup. App only receives from the injected channel, never closes it or calls `signal.Notify`, `signal.Stop`, or `os.Exit`. Nil disables signals and a closed channel is disabled. If shutdown began for another reason, the first subsequently received signal counts as the first signal; the next abandons shutdown. Signal counting spans startup and shutdown.
|
||||
* Wrap callback failures with module/worker name and phase and combine observed errors using `errors.Join`; retain every completed teardown error, including cancellation errors. A setup/service/worker error solely wrapping its callback context's cancellation/deadline caused by an already-established shutdown is expected and omitted; independent failures and non-cancellation components of joined errors remain errors. An instance-private cancellation cause proves App-initiated cancellation; retain ambiguous parent-propagated cancellation conservatively, including independently canceled descendants. The runtime context remains a direct child of its parent and preserves its deadline. The initiating setup failure remains an error. Parent cancellation returns its context error; graceful signal/normal completion returns nil if cleanup succeeds. Deadline abandonment adds `context.DeadlineExceeded`; signal abandonment adds an error wrapping `context.Canceled` with the signal as context. Retain errors already observed on abandonment, with no promise to collect later results or deterministic concurrent-error order. Do not recover panics.
|
||||
|
||||
### Consequences
|
||||
|
||||
* One coordinator owns admission, cancellation, hook ordering and completion accounting. Applications must route background work through `Go` and supply cooperative callbacks to claim graceful whole-app completion; this is an explicit boundary, not automatic goroutine discovery.
|
||||
* The shutdown bound requires asynchronous hook invocation and synchronized worker admission/completion, even when a consumer has no background workers. A blocked hook can consume the entire budget and leave earlier modules without teardown; a second signal deliberately accepts that same incomplete cleanup sooner.
|
||||
* Delivery should remain approximately 300–500 production lines plus focused race tests, a two-service package Example and public/package docs, in one PR. Verify startup/reverse ordering, partial rollback, service and worker failure propagation, normal completion, cancellation/error classification (including a first signal during setup with successful partial cleanup and rollback returning nil, and an initiating setup failure remaining observable), admission races, teardown unblocking workers, a shared deadline (including an uncooperative hook), second signals and joined errors. Test with injected signals, release blocked test goroutines explicitly, and use no real process signals.
|
||||
* No plan tier is needed. The accepted decision is implemented by [PR 13](https://gitea.auvem.com/corestack/go/pulls/13), including managed workers, bounded shutdown, focused race tests, the two-service example and public documentation. Independent standard and high implementation reviews are complete with no outstanding findings, and required local checks passed on the reviewed code. Publication is complete; CI and merge readiness are tracked in the PR. Delivery was reconciled against main at `46c4416ed44b4c8cfc69570b9bcb572b624d765e` plus the PR; this records implementation evidence, not a merge or release.
|
||||
@@ -3,29 +3,6 @@ module go.auvem.com/corestack
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
github.com/pressly/goose/v3 v3.29.0
|
||||
github.com/yuin/goldmark v1.8.6
|
||||
go.yaml.in/yaml/v3 v3.0.5
|
||||
modernc.org/sqlite v1.60.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.1.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.3 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v1.1.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/sethvargo/go-retry v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/sync v0.24.0 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
golang.org/x/text v0.43.0 // indirect
|
||||
modernc.org/libc v1.78.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
)
|
||||
@@ -1,81 +1,4 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
|
||||
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.3 h1:f8HoREISb3l5eO9coPgK7U3hKLhWGokOFVcThl5jrao=
|
||||
github.com/jackc/puddle/v2 v2.2.3/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
|
||||
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pressly/goose/v3 v3.29.0 h1:dtEnBCT7CqkHIUCdgrXsrmeisaN8gFkRU4Y5OEwif4o=
|
||||
github.com/pressly/goose/v3 v3.29.0/go.mod h1:plA4aP4+u+Q3FMTgbFbhF8TuAWpAdfWpoFGkJsIFWh0=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/sethvargo/go-retry v0.5.0 h1:JYfFKmVJ8ER06GReN1efzc5cm52BrVsFVzmcHiKJwfY=
|
||||
github.com/sethvargo/go-retry v0.5.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
|
||||
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
|
||||
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.30.1 h1:ZXInYH2CD24GPWSwOFPwB6JrnFt4/6d7ceW1Ukd30Bk=
|
||||
modernc.org/cc/v4 v4.30.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.36.2 h1:j6d5HuXtBxF00vEcI5RE0O6EIrG6RyRpgMiGV2IK9ys=
|
||||
modernc.org/ccgo/v4 v4.36.2/go.mod h1:Wh8QlPZq/US2DrsCc31FxJVBOEEcN2BBfla6OQDHydA=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
|
||||
modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.78.0 h1:Lpm+e9K6ruVYEGwWfuBO1xXtYEaXDouFOG1SuWjFDP4=
|
||||
modernc.org/libc v1.78.0/go.mod h1:wvxR55nOF2/66NkJoNV7/coXBTCvT3HR22rcqW6lOuA=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
|
||||
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,102 @@
|
||||
package lifecycle_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.auvem.com/corestack/lifecycle"
|
||||
)
|
||||
|
||||
func TestRealFlow(t *testing.T) {
|
||||
for _, failure := range []bool{false, true} {
|
||||
t.Run(fmt.Sprint(failure), func(t *testing.T) {
|
||||
signals := make(chan os.Signal, 2)
|
||||
app, err := lifecycle.New(lifecycle.Config{Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Signals: signals, ShutdownTimeout: time.Second})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ready := make(chan struct{}, 3)
|
||||
release := make(chan struct{})
|
||||
t.Cleanup(func() {
|
||||
select {
|
||||
case <-release:
|
||||
default:
|
||||
close(release)
|
||||
}
|
||||
})
|
||||
failed := errors.New("service failed")
|
||||
for _, name := range []string{"daemon", "muxer"} {
|
||||
err = app.Register(lifecycle.Module{Name: name, Setup: func(ctx context.Context) error {
|
||||
if name == "daemon" {
|
||||
return app.Go(ctx, "queue", func(ctx context.Context) error { ready <- struct{}{}; <-ctx.Done(); <-release; return ctx.Err() })
|
||||
}
|
||||
return nil
|
||||
}, Run: func(ctx context.Context) error {
|
||||
ready <- struct{}{}
|
||||
if name == "muxer" && failure {
|
||||
<-release
|
||||
return failed
|
||||
}
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}, Teardown: func(context.Context) error { return nil }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- app.Run(context.Background()) }()
|
||||
for range 3 {
|
||||
select {
|
||||
case <-ready:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("services did not start")
|
||||
}
|
||||
}
|
||||
if !failure {
|
||||
signals <- syscall.SIGTERM
|
||||
}
|
||||
close(release)
|
||||
select {
|
||||
case err = <-done:
|
||||
if failure && !errors.Is(err, failed) || !failure && err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Run blocked")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Example() {
|
||||
signals := make(chan os.Signal, 2)
|
||||
app, err := lifecycle.New(lifecycle.Config{Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Signals: signals})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ready := make(chan struct{}, 2)
|
||||
for _, name := range []string{"daemon", "muxer"} {
|
||||
err := app.Register(lifecycle.Module{
|
||||
Name: name,
|
||||
Setup: func(ctx context.Context) error {
|
||||
return app.Go(ctx, name+" worker", func(ctx context.Context) error { <-ctx.Done(); return ctx.Err() })
|
||||
},
|
||||
Run: func(ctx context.Context) error { ready <- struct{}{}; <-ctx.Done(); return ctx.Err() },
|
||||
Teardown: func(context.Context) error { return nil },
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
go func() { <-ready; <-ready; signals <- syscall.SIGTERM }()
|
||||
fmt.Println(app.Run(context.Background()))
|
||||
// Output: <nil>
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
// Package lifecycle supervises ordered module setup, concurrent services and
|
||||
// explicitly admitted workers within one shutdown budget. It owns cancellation
|
||||
// and reverse teardown, not signal subscriptions, process exit or raw goroutines.
|
||||
// Callbacks and the injected logger must cooperate: abandonment bounds Run's
|
||||
// return, but cannot kill callbacks or reclaim their resources.
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Module describes one resource owner. Setup acquires resources; Teardown releases
|
||||
// them after successful setup. A failing Setup owns its partial cleanup. Optional
|
||||
// Run starts only after all setups succeed. Callbacks must honor cancellation.
|
||||
type Module struct {
|
||||
// Name identifies the module in errors.
|
||||
Name string
|
||||
// Setup acquires resources before any service starts.
|
||||
Setup func(context.Context) error
|
||||
// Teardown releases resources after successful Setup.
|
||||
Teardown func(context.Context) error
|
||||
// Run optionally serves until completion or cancellation.
|
||||
Run func(context.Context) error
|
||||
}
|
||||
|
||||
// Config supplies application policy. Logger is required and must not block.
|
||||
// Zero ShutdownTimeout selects 30 seconds; negative values are invalid.
|
||||
// The caller owns Signals, its subscription and cleanup, filtering to SIGINT and
|
||||
// SIGTERM and buffering at least two signals. Nil or closed disables reception.
|
||||
type Config struct {
|
||||
// Logger receives lifecycle diagnostics and must not block.
|
||||
Logger *slog.Logger
|
||||
// Signals is a caller-owned, filtered signal source.
|
||||
Signals <-chan os.Signal
|
||||
// ShutdownTimeout bounds all shutdown work together.
|
||||
ShutdownTimeout time.Duration
|
||||
}
|
||||
|
||||
// App is a single-use coordinator constructed by New. Do not copy it. Register
|
||||
// modules before Run; Go may be called concurrently during setup and running.
|
||||
// App stores no context. After abandonment, callers own process exit and must not
|
||||
// reuse resources whose callbacks have not returned.
|
||||
type App struct {
|
||||
config Config
|
||||
mu sync.Mutex
|
||||
modules []Module
|
||||
used bool
|
||||
accepting bool
|
||||
stopping bool
|
||||
active int
|
||||
cancel context.CancelCauseFunc
|
||||
shutdownCause error
|
||||
stoppedAt time.Time
|
||||
errs []error
|
||||
wake chan struct{}
|
||||
}
|
||||
|
||||
// New validates and captures config without subscribing to signals or starting work.
|
||||
func New(config Config) (*App, error) {
|
||||
if config.Logger == nil {
|
||||
return nil, errors.New("lifecycle: logger is required")
|
||||
}
|
||||
if config.ShutdownTimeout < 0 {
|
||||
return nil, errors.New("lifecycle: negative shutdown timeout")
|
||||
}
|
||||
if config.ShutdownTimeout == 0 {
|
||||
config.ShutdownTimeout = 30 * time.Second
|
||||
}
|
||||
return &App{config: config, wake: make(chan struct{}, 1)}, nil
|
||||
}
|
||||
|
||||
// Register captures a uniquely named module with required setup and teardown.
|
||||
// Registration closes permanently when Run begins, including a failed run.
|
||||
func (a *App) Register(module Module) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.used {
|
||||
return errors.New("lifecycle: registration is closed")
|
||||
}
|
||||
if module.Name == "" || module.Setup == nil || module.Teardown == nil {
|
||||
return errors.New("lifecycle: module needs name, setup and teardown")
|
||||
}
|
||||
for _, m := range a.modules {
|
||||
if m.Name == module.Name {
|
||||
return fmt.Errorf("lifecycle: duplicate module %q", module.Name)
|
||||
}
|
||||
}
|
||||
a.modules = append(a.modules, module)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Go immediately launches and tracks fn, or returns an admission error without
|
||||
// launching it. ctx must be a callback's lifecycle context or a descendant;
|
||||
// ancestry is a caller precondition and cannot generally be checked. Detached
|
||||
// contexts forfeit graceful cancellation. Names are diagnostic and may repeat.
|
||||
// Callers must handle rejection; raw goroutines are outside App's guarantee.
|
||||
func (a *App) Go(ctx context.Context, name string, fn func(context.Context) error) error {
|
||||
if ctx == nil || name == "" || fn == nil {
|
||||
return errors.New("lifecycle: worker needs context, name and function")
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if !a.accepting {
|
||||
return errors.New("lifecycle: worker admission is closed")
|
||||
}
|
||||
a.launchLocked(ctx, "worker", name, fn)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) notifyLocked() {
|
||||
select {
|
||||
case a.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) stopLocked() {
|
||||
if !a.stopping {
|
||||
// invariant: closure and admission share the lock, so every accepted worker is counted.
|
||||
a.accepting = false
|
||||
a.stopping = true
|
||||
a.stoppedAt = time.Now()
|
||||
a.cancel(a.shutdownCause)
|
||||
}
|
||||
a.notifyLocked()
|
||||
}
|
||||
|
||||
// cancellationOnly recognizes expected cancellation without rebuilding error trees.
|
||||
func cancellationOnly(err, cancellation error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if err == cancellation {
|
||||
return true
|
||||
}
|
||||
if many, ok := err.(interface{ Unwrap() []error }); ok {
|
||||
children := many.Unwrap()
|
||||
for _, child := range children {
|
||||
if !cancellationOnly(child, cancellation) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(children) > 0
|
||||
}
|
||||
if one, ok := err.(interface{ Unwrap() error }); ok {
|
||||
return cancellationOnly(one.Unwrap(), cancellation)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *App) recordLocked(ctx context.Context, phase, name string, err error) {
|
||||
if a.stopping && context.Cause(ctx) == a.shutdownCause && cancellationOnly(err, ctx.Err()) {
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
a.errs = append(a.errs, fmt.Errorf("%s %q: %w", phase, name, err))
|
||||
a.stopLocked()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) launchLocked(ctx context.Context, phase, name string, fn func(context.Context) error) {
|
||||
a.active++
|
||||
go func() {
|
||||
err := fn(ctx)
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.recordLocked(ctx, phase, name, err)
|
||||
a.active--
|
||||
a.notifyLocked()
|
||||
}()
|
||||
}
|
||||
|
||||
// Run sets up modules in registration order, then runs services concurrently.
|
||||
// Every service and admitted worker must finish for normal completion. The first
|
||||
// failure, parent cancellation or signal cancels their shared context and closes
|
||||
// admission before reverse teardown. A fresh context retains parent values and
|
||||
// gives teardown and callback completion one deadline from the shutdown trigger.
|
||||
// In-flight setup is awaited within that budget before rollback; abandoned setup
|
||||
// is never torn down later. Deadline or second signal abandons outstanding work.
|
||||
// Observed failures are joined; expected callback cancellation during established
|
||||
// shutdown is omitted only for cancellation-only error trees. Mixed errors remain
|
||||
// intact to preserve wrapper types, identity and matching, so may still match
|
||||
// cancellation. Initiating failures and every teardown error remain.
|
||||
// Cancellation is omitted only when context.Cause proves App caused it; parent
|
||||
// propagation and independently canceled descendants are retained conservatively.
|
||||
// Run never recovers panics, closes Signals or exits the process.
|
||||
func (a *App) Run(parent context.Context) error {
|
||||
if parent == nil {
|
||||
return errors.New("lifecycle: nil run context")
|
||||
}
|
||||
a.mu.Lock()
|
||||
if a.used {
|
||||
a.mu.Unlock()
|
||||
return errors.New("lifecycle: app already run")
|
||||
}
|
||||
ctx, cancel := context.WithCancelCause(parent)
|
||||
a.cancel = cancel
|
||||
a.shutdownCause = errors.New("lifecycle shutdown")
|
||||
a.used = true
|
||||
a.accepting = true
|
||||
modules := a.modules
|
||||
a.mu.Unlock()
|
||||
defer cancel(nil)
|
||||
signals := a.config.Signals
|
||||
signalCount := 0
|
||||
var shutdown context.Context
|
||||
var shutdownCancel context.CancelFunc
|
||||
var setupDone <-chan error
|
||||
completed := 0
|
||||
var teardownDone <-chan error
|
||||
teardownIndex := -1
|
||||
startup := true
|
||||
parentDone := parent.Done()
|
||||
receiveSignal := func(sig os.Signal) error {
|
||||
signalCount++
|
||||
if signalCount == 2 {
|
||||
if shutdownCancel != nil {
|
||||
shutdownCancel()
|
||||
}
|
||||
return fmt.Errorf("second signal %v: %w", sig, context.Canceled)
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.stopLocked()
|
||||
a.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
for {
|
||||
// invariant: queued abandonment is observed before starting another hook.
|
||||
select {
|
||||
case sig, ok := <-signals:
|
||||
if !ok {
|
||||
signals = nil
|
||||
} else if err := receiveSignal(sig); err != nil {
|
||||
return a.result(parent, err)
|
||||
}
|
||||
continue
|
||||
default:
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
if !a.stopping && parent.Err() != nil {
|
||||
a.stopLocked()
|
||||
parentDone = nil
|
||||
}
|
||||
if !a.stopping && startup && setupDone == nil {
|
||||
if completed < len(modules) {
|
||||
m := modules[completed]
|
||||
// invariant: buffered hook results cannot strand a sender after abandonment.
|
||||
result := make(chan error, 1)
|
||||
setupDone = result
|
||||
go func() {
|
||||
err := m.Setup(ctx)
|
||||
a.mu.Lock()
|
||||
a.recordLocked(ctx, "setup", m.Name, err)
|
||||
a.mu.Unlock()
|
||||
result <- err
|
||||
}()
|
||||
} else {
|
||||
startup = false
|
||||
for _, m := range modules {
|
||||
if m.Run != nil {
|
||||
a.launchLocked(ctx, "run", m.Name, m.Run)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !a.stopping && !startup && a.active == 0 {
|
||||
a.stopLocked()
|
||||
}
|
||||
if a.stopping && shutdown == nil {
|
||||
created, release := context.WithDeadline(context.WithoutCancel(parent), a.stoppedAt.Add(a.config.ShutdownTimeout))
|
||||
shutdown, shutdownCancel = created, release
|
||||
defer release()
|
||||
teardownIndex = completed - 1
|
||||
a.config.Logger.Debug("lifecycle shutdown started")
|
||||
}
|
||||
stopping := a.stopping
|
||||
active := a.active
|
||||
a.mu.Unlock()
|
||||
|
||||
if stopping {
|
||||
if err := shutdown.Err(); err != nil {
|
||||
return a.result(parent, err)
|
||||
}
|
||||
if setupDone == nil && teardownDone == nil {
|
||||
if teardownIndex >= 0 {
|
||||
m := modules[teardownIndex]
|
||||
result := make(chan error, 1)
|
||||
teardownDone = result
|
||||
go func() { result <- m.Teardown(shutdown) }()
|
||||
} else if active == 0 {
|
||||
return a.result(parent, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
var deadline <-chan struct{}
|
||||
if shutdown != nil {
|
||||
deadline = shutdown.Done()
|
||||
}
|
||||
select {
|
||||
case err := <-setupDone:
|
||||
setupDone = nil
|
||||
if err == nil {
|
||||
completed++
|
||||
if stopping {
|
||||
teardownIndex = completed - 1
|
||||
}
|
||||
}
|
||||
case err := <-teardownDone:
|
||||
teardownDone = nil
|
||||
if err != nil {
|
||||
a.mu.Lock()
|
||||
a.errs = append(a.errs, fmt.Errorf("teardown %q: %w", modules[teardownIndex].Name, err))
|
||||
a.mu.Unlock()
|
||||
}
|
||||
teardownIndex--
|
||||
case <-a.wake:
|
||||
case <-parentDone:
|
||||
a.mu.Lock()
|
||||
a.stopLocked()
|
||||
a.mu.Unlock()
|
||||
parentDone = nil
|
||||
case sig, ok := <-signals:
|
||||
if !ok {
|
||||
signals = nil
|
||||
continue
|
||||
}
|
||||
if err := receiveSignal(sig); err != nil {
|
||||
return a.result(parent, err)
|
||||
}
|
||||
case <-deadline:
|
||||
return a.result(parent, context.DeadlineExceeded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) result(parent context.Context, extra error) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
// invariant: every completion and abandonment retains parent cancellation exactly once.
|
||||
return errors.Join(append(a.errs, parent.Err(), extra)...)
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newApp(t *testing.T, signals <-chan os.Signal, timeout time.Duration) *App {
|
||||
t.Helper()
|
||||
a, err := New(Config{Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Signals: signals, ShutdownTimeout: timeout})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
func add(t *testing.T, a *App, m Module) {
|
||||
t.Helper()
|
||||
if m.Setup == nil {
|
||||
m.Setup = func(context.Context) error { return nil }
|
||||
}
|
||||
if m.Teardown == nil {
|
||||
m.Teardown = func(context.Context) error { return nil }
|
||||
}
|
||||
if err := a.Register(m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func run(a *App, ctx context.Context) <-chan error {
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- a.Run(ctx) }()
|
||||
return done
|
||||
}
|
||||
func receive(t *testing.T, done <-chan error) error {
|
||||
t.Helper()
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Run blocked")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
func wait(t *testing.T, ch <-chan struct{}) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("callback blocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidation(t *testing.T) {
|
||||
if _, err := New(Config{}); err == nil {
|
||||
t.Fatal("nil logger accepted")
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := New(Config{Logger: logger, ShutdownTimeout: -1}); err == nil {
|
||||
t.Fatal("negative timeout accepted")
|
||||
}
|
||||
a := newApp(t, nil, 0)
|
||||
if a.config.ShutdownTimeout != 30*time.Second {
|
||||
t.Fatal("wrong default")
|
||||
}
|
||||
if a.Register(Module{Name: "bad"}) == nil {
|
||||
t.Fatal("invalid module accepted")
|
||||
}
|
||||
add(t, a, Module{Name: "one"})
|
||||
if a.Register(a.modules[0]) == nil {
|
||||
t.Fatal("duplicate accepted")
|
||||
}
|
||||
if a.Go(context.Background(), "early", func(context.Context) error { return nil }) == nil {
|
||||
t.Fatal("early worker accepted")
|
||||
}
|
||||
if a.Run(nil) == nil {
|
||||
t.Fatal("nil context accepted")
|
||||
}
|
||||
if err := a.Run(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Run(context.Background()) == nil || a.Register(Module{}) == nil {
|
||||
t.Fatal("reuse accepted")
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
ctx context.Context
|
||||
name string
|
||||
fn func(context.Context) error
|
||||
}{{nil, "x", func(context.Context) error { return nil }}, {context.Background(), "", func(context.Context) error { return nil }}, {context.Background(), "x", nil}} {
|
||||
if a.Go(tc.ctx, tc.name, tc.fn) == nil {
|
||||
t.Fatal("invalid worker accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderRollbackAndJoinedErrors(t *testing.T) {
|
||||
for _, fail := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "normal", true: "rollback"}[fail], func(t *testing.T) {
|
||||
a := newApp(t, nil, 0)
|
||||
var order []string
|
||||
setupErr := errors.New("setup failed")
|
||||
first := errors.New("cleanup first")
|
||||
second := errors.New("cleanup second")
|
||||
for _, name := range []string{"one", "two", "three"} {
|
||||
add(t, a, Module{Name: name, Setup: func(context.Context) error {
|
||||
order = append(order, "setup "+name)
|
||||
if fail && name == "three" {
|
||||
return setupErr
|
||||
}
|
||||
return nil
|
||||
}, Teardown: func(context.Context) error {
|
||||
order = append(order, "stop "+name)
|
||||
if name == "one" {
|
||||
return first
|
||||
}
|
||||
if name == "two" {
|
||||
return second
|
||||
}
|
||||
return nil
|
||||
}})
|
||||
}
|
||||
err := a.Run(context.Background())
|
||||
want := []string{"setup one", "setup two", "setup three"}
|
||||
if !fail {
|
||||
want = append(want, "stop three")
|
||||
}
|
||||
want = append(want, "stop two", "stop one")
|
||||
if !reflect.DeepEqual(order, want) {
|
||||
t.Fatalf("order %v", order)
|
||||
}
|
||||
if !errors.Is(err, first) || !errors.Is(err, second) || errors.Is(err, setupErr) != fail {
|
||||
t.Fatalf("errors %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllWorkCompletion(t *testing.T) {
|
||||
for _, kind := range []string{"service", "worker"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
a := newApp(t, nil, 0)
|
||||
ready := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var once sync.Once
|
||||
defer once.Do(func() { close(release) })
|
||||
fn := func(context.Context) error { close(ready); <-release; return nil }
|
||||
m := Module{Name: "long"}
|
||||
if kind == "service" {
|
||||
m.Run = fn
|
||||
} else {
|
||||
m.Setup = func(ctx context.Context) error { return a.Go(ctx, "long", fn) }
|
||||
}
|
||||
add(t, a, m)
|
||||
add(t, a, Module{Name: "short", Run: func(context.Context) error { return nil }})
|
||||
done := run(a, context.Background())
|
||||
wait(t, ready)
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatalf("early completion: %v", err)
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
}
|
||||
once.Do(func() { close(release) })
|
||||
if err := receive(t, done); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerFailureAndTeardownUnblocks(t *testing.T) {
|
||||
a := newApp(t, nil, time.Second)
|
||||
released := make(chan struct{})
|
||||
ready := make(chan struct{})
|
||||
failure := errors.New("worker failed")
|
||||
add(t, a, Module{Name: "resource", Setup: func(ctx context.Context) error {
|
||||
return a.Go(ctx, "waiting", func(ctx context.Context) error { close(ready); <-ctx.Done(); <-released; return ctx.Err() })
|
||||
}, Teardown: func(ctx context.Context) error {
|
||||
if ctx.Err() != nil {
|
||||
t.Errorf("teardown canceled early: %v", ctx.Err())
|
||||
}
|
||||
close(released)
|
||||
return nil
|
||||
}, Run: func(context.Context) error { <-ready; return failure }})
|
||||
err := a.Run(context.Background())
|
||||
if !errors.Is(err, failure) || errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("errors %v", err)
|
||||
}
|
||||
b := newApp(t, nil, 0)
|
||||
add(t, b, Module{Name: "worker", Setup: func(ctx context.Context) error {
|
||||
return b.Go(ctx, "failing", func(context.Context) error { return failure })
|
||||
}, Run: func(ctx context.Context) error { <-ctx.Done(); return ctx.Err() }})
|
||||
if err = b.Run(context.Background()); !errors.Is(err, failure) {
|
||||
t.Fatalf("worker error %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalDuringSetup(t *testing.T) {
|
||||
aSignals := make(chan os.Signal, 2)
|
||||
a := newApp(t, aSignals, time.Second)
|
||||
entered := make(chan struct{})
|
||||
rolled := false
|
||||
partialCleaned := false
|
||||
add(t, a, Module{Name: "ready", Teardown: func(context.Context) error { rolled = true; return nil }})
|
||||
add(t, a, Module{Name: "partial", Setup: func(ctx context.Context) error {
|
||||
close(entered)
|
||||
<-ctx.Done()
|
||||
partialCleaned = true
|
||||
return errors.Join(ctx.Err(), nil)
|
||||
}, Teardown: func(context.Context) error { t.Error("failed setup torn down"); return nil }})
|
||||
add(t, a, Module{Name: "never", Setup: func(context.Context) error { t.Error("late setup started"); return nil }})
|
||||
done := run(a, context.Background())
|
||||
wait(t, entered)
|
||||
aSignals <- syscall.SIGINT
|
||||
if err := receive(t, done); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !rolled || !partialCleaned {
|
||||
t.Fatal("cleanup missing")
|
||||
}
|
||||
b := newApp(t, nil, 0)
|
||||
add(t, b, Module{Name: "initiator", Setup: func(context.Context) error { return context.Canceled }})
|
||||
if err := b.Run(context.Background()); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("initiator lost: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMixedErrorsAndParentValues(t *testing.T) {
|
||||
type key struct{}
|
||||
parent, cancel := context.WithCancel(context.WithValue(context.Background(), key{}, "value"))
|
||||
defer cancel()
|
||||
a := newApp(t, nil, time.Second)
|
||||
ready := make(chan struct{})
|
||||
independent := errors.New("independent")
|
||||
teardownErr := errors.New("teardown")
|
||||
add(t, a, Module{Name: "mixed", Run: func(ctx context.Context) error {
|
||||
close(ready)
|
||||
<-ctx.Done()
|
||||
return errors.Join(ctx.Err(), independent)
|
||||
}, Teardown: func(ctx context.Context) error {
|
||||
if ctx.Value(key{}) != "value" || ctx.Err() != nil {
|
||||
t.Error("shutdown context lost values or inherited cancellation")
|
||||
}
|
||||
return errors.Join(teardownErr, context.Canceled)
|
||||
}})
|
||||
done := run(a, parent)
|
||||
wait(t, ready)
|
||||
cancel()
|
||||
err := receive(t, done)
|
||||
for _, want := range []error{context.Canceled, independent, teardownErr} {
|
||||
if !errors.Is(err, want) {
|
||||
t.Fatalf("missing %v: %v", want, err)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mixed") {
|
||||
t.Fatal("missing diagnostic name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedHooksAndWorkers(t *testing.T) {
|
||||
for _, kind := range []string{"setup", "teardown", "worker"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
signals := make(chan os.Signal, 2)
|
||||
a := newApp(t, signals, 30*time.Millisecond)
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
exited := make(chan struct{})
|
||||
defer func() { close(release); wait(t, exited) }()
|
||||
blocked := func(context.Context) error { close(entered); <-release; close(exited); return nil }
|
||||
var torn atomic.Int32
|
||||
m := Module{Name: "blocked", Teardown: func(context.Context) error { torn.Add(1); return nil }}
|
||||
switch kind {
|
||||
case "setup":
|
||||
m.Setup = blocked
|
||||
case "teardown":
|
||||
m.Teardown = blocked
|
||||
case "worker":
|
||||
m.Setup = func(ctx context.Context) error { return a.Go(ctx, "blocked", blocked) }
|
||||
}
|
||||
add(t, a, m)
|
||||
done := run(a, context.Background())
|
||||
wait(t, entered)
|
||||
start := time.Now()
|
||||
signals <- syscall.SIGTERM
|
||||
err := receive(t, done)
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("deadline missing: %v", err)
|
||||
}
|
||||
if time.Since(start) > time.Second {
|
||||
t.Fatal("shutdown exceeded bound")
|
||||
}
|
||||
if kind == "setup" && torn.Load() != 0 {
|
||||
t.Fatal("abandoned setup torn down")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecondSignalAndClosedSource(t *testing.T) {
|
||||
for _, trigger := range []string{"signal", "failure"} {
|
||||
t.Run(trigger, func(t *testing.T) {
|
||||
signals := make(chan os.Signal, 2)
|
||||
a := newApp(t, signals, time.Second)
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
exited := make(chan struct{})
|
||||
defer func() { close(release); wait(t, exited) }()
|
||||
failure := errors.New("failed")
|
||||
ready := make(chan struct{})
|
||||
add(t, a, Module{Name: "blocked", Run: func(ctx context.Context) error {
|
||||
close(ready)
|
||||
if trigger == "failure" {
|
||||
return failure
|
||||
}
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}, Teardown: func(context.Context) error { close(entered); <-release; close(exited); return nil }})
|
||||
done := run(a, context.Background())
|
||||
wait(t, ready)
|
||||
if trigger == "signal" {
|
||||
signals <- syscall.SIGINT
|
||||
}
|
||||
wait(t, entered)
|
||||
if trigger == "failure" {
|
||||
signals <- syscall.SIGINT
|
||||
}
|
||||
signals <- syscall.SIGTERM
|
||||
err := receive(t, done)
|
||||
if !errors.Is(err, context.Canceled) || errors.Is(err, failure) != (trigger == "failure") {
|
||||
t.Fatalf("abandonment %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
signals := make(chan os.Signal)
|
||||
close(signals)
|
||||
a := newApp(t, signals, 0)
|
||||
if err := a.Run(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdmissionRace(t *testing.T) {
|
||||
for range 50 {
|
||||
a := newApp(t, nil, time.Second)
|
||||
parent, cancel := context.WithCancel(context.Background())
|
||||
ready := make(chan context.Context, 1)
|
||||
add(t, a, Module{Name: "service", Run: func(ctx context.Context) error { ready <- ctx; <-ctx.Done(); return ctx.Err() }})
|
||||
done := run(a, parent)
|
||||
ctx := <-ready
|
||||
var accepted, finished atomic.Int32
|
||||
var callers sync.WaitGroup
|
||||
for range 20 {
|
||||
callers.Go(func() {
|
||||
if err := a.Go(ctx, "racer", func(context.Context) error { finished.Add(1); return nil }); err == nil {
|
||||
accepted.Add(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
cancel()
|
||||
callers.Wait()
|
||||
err := receive(t, done)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("parent error %v", err)
|
||||
}
|
||||
if accepted.Load() != finished.Load() {
|
||||
t.Fatal("accepted worker escaped completion")
|
||||
}
|
||||
if a.Go(ctx, "late", func(context.Context) error { t.Error("late launch"); return nil }) == nil {
|
||||
t.Fatal("late admission")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndependentDescendantCancellation(t *testing.T) {
|
||||
for _, mixed := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "cancellation-only", true: "mixed"}[mixed], func(t *testing.T) {
|
||||
signals := make(chan os.Signal, 2)
|
||||
a := newApp(t, signals, time.Second)
|
||||
canceled := make(chan struct{})
|
||||
allowReturn := make(chan struct{})
|
||||
other := errors.New("other")
|
||||
add(t, a, Module{Name: "owner", Setup: func(ctx context.Context) error {
|
||||
child, cancel := context.WithCancel(ctx)
|
||||
err := a.Go(child, "independent", func(ctx context.Context) error {
|
||||
cancel()
|
||||
close(canceled)
|
||||
<-allowReturn
|
||||
err := ctx.Err()
|
||||
if mixed {
|
||||
err = errors.Join(err, other)
|
||||
}
|
||||
return fmt.Errorf("worker result: %w", err)
|
||||
})
|
||||
if err != nil {
|
||||
cancel()
|
||||
}
|
||||
return err
|
||||
}, Teardown: func(context.Context) error { close(allowReturn); return nil }})
|
||||
done := run(a, context.Background())
|
||||
wait(t, canceled)
|
||||
signals <- syscall.SIGTERM
|
||||
if err := receive(t, done); !errors.Is(err, context.Canceled) || errors.Is(err, other) != mixed {
|
||||
t.Fatalf("independent cancellation lost: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParentCancellationDuringShutdown(t *testing.T) {
|
||||
for _, boundary := range []string{"completion", "deadline", "second-signal"} {
|
||||
t.Run(boundary, func(t *testing.T) {
|
||||
iterations := 1
|
||||
if boundary == "completion" {
|
||||
iterations = 1000
|
||||
}
|
||||
for range iterations {
|
||||
parent, cancel := context.WithCancel(context.Background())
|
||||
signals := make(chan os.Signal, 2)
|
||||
timeout := time.Second
|
||||
if boundary == "deadline" {
|
||||
timeout = 20 * time.Millisecond
|
||||
}
|
||||
a := newApp(t, signals, timeout)
|
||||
exited := make(chan struct{})
|
||||
add(t, a, Module{Name: "last", Teardown: func(ctx context.Context) error {
|
||||
defer close(exited)
|
||||
cancel()
|
||||
switch boundary {
|
||||
case "deadline":
|
||||
<-ctx.Done()
|
||||
case "second-signal":
|
||||
signals <- syscall.SIGINT
|
||||
signals <- syscall.SIGTERM
|
||||
<-ctx.Done()
|
||||
}
|
||||
return nil
|
||||
}})
|
||||
err := a.Run(parent)
|
||||
cancel()
|
||||
wait(t, exited)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("parent cancellation lost: %v", err)
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) != (boundary == "deadline") {
|
||||
t.Fatalf("unexpected shutdown result: %v", err)
|
||||
}
|
||||
parentErrors := 0
|
||||
for _, child := range err.(interface{ Unwrap() []error }).Unwrap() {
|
||||
if child == context.Canceled {
|
||||
parentErrors++
|
||||
}
|
||||
}
|
||||
if parentErrors != 1 {
|
||||
t.Fatalf("parent error recorded %d times: %v", parentErrors, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuccessfulInterruptedSetupRollsBack(t *testing.T) {
|
||||
signals := make(chan os.Signal, 2)
|
||||
a := newApp(t, signals, time.Second)
|
||||
entered := make(chan struct{})
|
||||
var order []string
|
||||
for _, name := range []string{"first", "interrupted"} {
|
||||
add(t, a, Module{Name: name, Setup: func(ctx context.Context) error {
|
||||
if name == "interrupted" {
|
||||
close(entered)
|
||||
<-ctx.Done()
|
||||
}
|
||||
return nil
|
||||
}, Teardown: func(context.Context) error { order = append(order, name); return nil }})
|
||||
}
|
||||
done := run(a, context.Background())
|
||||
wait(t, entered)
|
||||
signals <- syscall.SIGINT
|
||||
if err := receive(t, done); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(order, []string{"interrupted", "first"}) {
|
||||
t.Fatalf("rollback %v", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedDeadline(t *testing.T) {
|
||||
a := newApp(t, nil, 80*time.Millisecond)
|
||||
var firstDeadline time.Time
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
exited := make(chan struct{})
|
||||
defer func() { close(release); wait(t, exited) }()
|
||||
var skipped atomic.Bool
|
||||
add(t, a, Module{Name: "never", Teardown: func(context.Context) error { skipped.Store(true); return nil }})
|
||||
add(t, a, Module{Name: "blocked", Teardown: func(ctx context.Context) error {
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok || !deadline.Equal(firstDeadline) {
|
||||
t.Error("deadline reset between hooks")
|
||||
}
|
||||
close(entered)
|
||||
<-release
|
||||
close(exited)
|
||||
return nil
|
||||
}})
|
||||
add(t, a, Module{Name: "first", Teardown: func(ctx context.Context) error {
|
||||
firstDeadline, _ = ctx.Deadline()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
return nil
|
||||
}})
|
||||
done := run(a, context.Background())
|
||||
wait(t, entered)
|
||||
if err := receive(t, done); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("deadline %v", err)
|
||||
}
|
||||
if skipped.Load() {
|
||||
t.Fatal("hook started after abandonment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalCompletionAdmissionRace(t *testing.T) {
|
||||
for range 50 {
|
||||
a := newApp(t, nil, time.Second)
|
||||
ready := make(chan context.Context, 1)
|
||||
release := make(chan struct{})
|
||||
add(t, a, Module{Name: "service", Run: func(ctx context.Context) error { ready <- ctx; <-release; return nil }})
|
||||
done := run(a, context.Background())
|
||||
ctx := <-ready
|
||||
var accepted, finished atomic.Int32
|
||||
var callers sync.WaitGroup
|
||||
for range 20 {
|
||||
callers.Go(func() {
|
||||
if a.Go(ctx, "racer", func(context.Context) error { finished.Add(1); return nil }) == nil {
|
||||
accepted.Add(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
close(release)
|
||||
callers.Wait()
|
||||
if err := receive(t, done); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if accepted.Load() != finished.Load() {
|
||||
t.Fatal("normal completion lost worker")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartupWaitsForSetupDespiteEmptyWorkers(t *testing.T) {
|
||||
a := newApp(t, nil, time.Second)
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
defer func() {
|
||||
select {
|
||||
case <-release:
|
||||
default:
|
||||
close(release)
|
||||
}
|
||||
}()
|
||||
add(t, a, Module{Name: "setup", Setup: func(context.Context) error { close(entered); <-release; return nil }})
|
||||
done := run(a, context.Background())
|
||||
wait(t, entered)
|
||||
if a.Run(context.Background()) == nil {
|
||||
t.Fatal("concurrent Run accepted")
|
||||
}
|
||||
if a.Register(Module{}) == nil {
|
||||
t.Fatal("registration remained open")
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatalf("ended during setup: %v", err)
|
||||
default:
|
||||
}
|
||||
close(release)
|
||||
if err := receive(t, done); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMixedShutdownErrorPreservesCause(t *testing.T) {
|
||||
signals := make(chan os.Signal, 2)
|
||||
a := newApp(t, signals, time.Second)
|
||||
ready := make(chan struct{})
|
||||
independent := &os.PathError{Op: "read", Path: "queue", Err: io.ErrUnexpectedEOF}
|
||||
add(t, a, Module{Name: "mixed", Run: func(ctx context.Context) error {
|
||||
close(ready)
|
||||
<-ctx.Done()
|
||||
return fmt.Errorf("outer: %w", errors.Join(fmt.Errorf("cancellation: %w", ctx.Err()), independent))
|
||||
}})
|
||||
done := run(a, context.Background())
|
||||
wait(t, ready)
|
||||
signals <- syscall.SIGTERM
|
||||
err := receive(t, done)
|
||||
var pathErr *os.PathError
|
||||
if !errors.As(err, &pathErr) || pathErr != independent || !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
t.Fatalf("independent cause lost: %v", err)
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("mixed wrapper was reconstructed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// matchingError exercises wrapper-owned matching independently of its child.
|
||||
type matchingError struct {
|
||||
child error
|
||||
match error
|
||||
}
|
||||
|
||||
func (e *matchingError) Error() string { return "custom: " + e.child.Error() }
|
||||
func (e *matchingError) Unwrap() error { return e.child }
|
||||
func (e *matchingError) Is(target error) bool { return target == e.match }
|
||||
|
||||
func TestMixedShutdownErrorPreservesWrapper(t *testing.T) {
|
||||
for _, kind := range []string{"path", "custom"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
signals := make(chan os.Signal, 2)
|
||||
a := newApp(t, signals, time.Second)
|
||||
ready := make(chan struct{})
|
||||
match := errors.New("custom match")
|
||||
var original error
|
||||
add(t, a, Module{Name: "mixed", Run: func(ctx context.Context) error {
|
||||
close(ready)
|
||||
<-ctx.Done()
|
||||
joined := errors.Join(ctx.Err(), io.ErrUnexpectedEOF)
|
||||
if kind == "path" {
|
||||
original = &os.PathError{Op: "read", Path: "queue", Err: joined}
|
||||
} else {
|
||||
original = &matchingError{child: joined, match: match}
|
||||
}
|
||||
return original
|
||||
}})
|
||||
done := run(a, context.Background())
|
||||
wait(t, ready)
|
||||
signals <- syscall.SIGTERM
|
||||
err := receive(t, done)
|
||||
if !errors.Is(err, original) || !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
t.Fatalf("wrapper identity or inner failure lost: %v", err)
|
||||
}
|
||||
if kind == "path" {
|
||||
var pathErr *os.PathError
|
||||
if !errors.As(err, &pathErr) || pathErr != original {
|
||||
t.Fatalf("typed wrapper lost: %v", err)
|
||||
}
|
||||
} else {
|
||||
var custom *matchingError
|
||||
if !errors.As(err, &custom) || custom != original || !errors.Is(err, match) {
|
||||
t.Fatalf("custom matching lost: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancellationOnly(t *testing.T) {
|
||||
for _, err := range []error{
|
||||
context.Canceled,
|
||||
fmt.Errorf("wrapped: %w", context.Canceled),
|
||||
&os.PathError{Op: "read", Path: "queue", Err: errors.Join(context.Canceled, fmt.Errorf("wrapped: %w", context.Canceled))},
|
||||
} {
|
||||
if !cancellationOnly(err, context.Canceled) {
|
||||
t.Errorf("expected cancellation not recognized: %v", err)
|
||||
}
|
||||
}
|
||||
for _, err := range []error{nil, io.ErrUnexpectedEOF, context.DeadlineExceeded, errors.Join(context.Canceled, io.ErrUnexpectedEOF)} {
|
||||
if cancellationOnly(err, context.Canceled) {
|
||||
t.Errorf("independent error classified as cancellation: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
# migrate
|
||||
|
||||
`go.auvem.com/corestack/migrate` sets the house configuration for
|
||||
[Goose's Provider API](https://pressly.github.io/goose/documentation/provider/).
|
||||
It returns `*goose.Provider` directly: Goose owns execution, version tracking,
|
||||
status, and errors. There is no lifecycle dependency or background work.
|
||||
|
||||
Embed migrations in the application's binary and run them before serving traffic:
|
||||
|
||||
```go
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"log/slog"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
"go.auvem.com/corestack/migrate"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrations embed.FS
|
||||
|
||||
func migrateDatabase(ctx context.Context, db *sql.DB, logger *slog.Logger) error {
|
||||
provider, err := migrate.New(db, goose.DialectPostgres, migrations, "migrations", logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = provider.Up(ctx)
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
The caller opens and closes `db`, selects a matching database driver and dialect,
|
||||
supplies a nonnil logger, and controls the operation context/deadline. Construction
|
||||
is offline; SQL is parsed lazily by Goose. `Provider.Close` also closes `db`, so do
|
||||
not defer it when sharing the pool with the application. Use `"."` for SQL files
|
||||
at the embedded root. Files in the selected directory are discovered directly,
|
||||
without recursively searching subdirectories.
|
||||
|
||||
Use unique positive versions and Goose's migration creator naming convention:
|
||||
zero-padded sequential names such as `00007_users.sql` and `00010_events.sql`, or
|
||||
fixed-width timestamps such as `20261027114500_create_users.sql`. Filenames must
|
||||
sort lexically in numeric version order, including when mixing naming schemes;
|
||||
choose padding wide enough for the complete history. Goose v3.29.0 preserves
|
||||
lexical source order for SQL-only providers, so arbitrary unpadded names such as
|
||||
`7_users.sql` and `10_events.sql` are unsupported by this house convention: they
|
||||
can produce incorrect latest versions and source/status ordering. This package
|
||||
does not rename or normalize migration files.
|
||||
|
||||
Never derive a version from a file count or add one to the current version.
|
||||
Keep applied migrations immutable and ship the complete history. Goose rejects
|
||||
newly introduced migrations below the current version by default.
|
||||
|
||||
```sql
|
||||
-- +goose Up
|
||||
CREATE TABLE users (id BIGINT PRIMARY KEY);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE users;
|
||||
```
|
||||
|
||||
Goose runs each migration in a transaction by default. Its annotations support
|
||||
statement blocks and explicit `NO TRANSACTION` where required; such migrations
|
||||
and nontransactional database DDL can leave partial effects on failure. Down
|
||||
migrations execute application-authored SQL and may delete data.
|
||||
|
||||
| Operation | Provider method |
|
||||
| --- | --- |
|
||||
| Apply all pending migrations | `Up(ctx)` |
|
||||
| Apply pending versions through a target, inclusive | `UpTo(ctx, version)` |
|
||||
| Roll back versions above a target; zero removes all | `DownTo(ctx, version)` |
|
||||
| Inspect each migration's state | `Status(ctx)` |
|
||||
| Current database version | `GetDBVersion(ctx)` |
|
||||
| Current and latest available versions | `GetVersions(ctx)` |
|
||||
| Detect pending migrations | `HasPending(ctx)` |
|
||||
|
||||
Targets are numeric version bounds, so gaps and timestamps work without special
|
||||
handling. Up on a fully migrated database succeeds with no results. Operations
|
||||
may create Goose's `goose_db_version` table even when only inspecting state.
|
||||
Execution errors retain Goose's details; use `errors.As` with
|
||||
`*goose.PartialError` to inspect applied and failed migrations. Transactional
|
||||
failure leaves the database at the last committed migration version. Operation
|
||||
logs use the injected slog logger; callers handle and log returned errors.
|
||||
|
||||
For Go functions, pass migrations created with `goose.NewGoMigration(version,
|
||||
up, down)` as the trailing arguments to `migrate.New`. Use `goose.GoFunc.RunTx`
|
||||
for transactional work or `RunDB` for work requiring its own connections. Each
|
||||
provider should receive its own migration instances. Versions must be unique
|
||||
across SQL and Go migrations. Global registration is disabled; no `init` or
|
||||
`goose.AddMigration` calls are needed.
|
||||
|
||||
Postgres providers automatically use Goose's default session advisory locker.
|
||||
Concurrent startup calls from separate providers/processes serialize migration
|
||||
execution and recheck applied versions under the lock. The default lock ID
|
||||
serializes Goose migrations throughout the same database, including different
|
||||
schemas; lock acquisition retries for about five minutes and unlock retries for
|
||||
about one minute, subject to the operation context. Use a direct connection or
|
||||
a session-pooling proxy: transaction pooling cannot preserve session locks.
|
||||
`RunDB` Go migrations need spare pool connections while the locked session is
|
||||
held; avoid a one-connection pool for them.
|
||||
|
||||
Other dialects have no cross-provider/process lock configured by this package.
|
||||
Serialize migration runs externally, for example with a single deployment job.
|
||||
SQLite tests validate the SQL convention, not concurrent daemon startup safety.
|
||||
|
||||
Run the integration suite against an existing Postgres database with schema
|
||||
creation privileges:
|
||||
|
||||
```sh
|
||||
MIGRATE_POSTGRES_DSN='postgres://user:password@localhost/testdb?sslmode=disable' go test -race ./migrate
|
||||
```
|
||||
|
||||
Tests create and clean only random schemas they own. Without a DSN, Postgres
|
||||
tests skip locally; in CI a missing DSN fails, and the CI job supplies a Postgres
|
||||
service. SQLite tests always run in process using temporary databases.
|
||||
@@ -1,65 +0,0 @@
|
||||
// Package migrate configures Goose providers for embedded application migrations.
|
||||
// Applications own their database, migration files, and startup ordering.
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
"github.com/pressly/goose/v3/lock"
|
||||
)
|
||||
|
||||
// New returns a provider for SQL files in dir and optional provider-local Go
|
||||
// migrations. Pass "." for files at the embedded root and a nonnil logger.
|
||||
// The selected directory must exist, including when using only Go migrations.
|
||||
// Construction does not connect to db; the caller owns and closes db.
|
||||
//
|
||||
// PostgreSQL uses Goose's session advisory locker with its default lock ID and
|
||||
// retry limits. Other dialects require the caller to serialize migration runs
|
||||
// across providers/processes. PostgreSQL connections must preserve session
|
||||
// affinity (transaction-pooling proxies cannot safely hold session locks).
|
||||
//
|
||||
// Use the returned Provider's Up, UpTo, DownTo, Status, GetDBVersion, GetVersions,
|
||||
// and HasPending methods directly. Filenames must sort lexically in numeric
|
||||
// version order: use zero-padded sequential or fixed-width timestamp prefixes,
|
||||
// as produced by Goose's migration creator. Global Go registration is ignored.
|
||||
func New(db *sql.DB, dialect goose.Dialect, files embed.FS, dir string, logger *slog.Logger, migrations ...*goose.Migration) (*goose.Provider, error) {
|
||||
for i, migration := range migrations {
|
||||
if migration == nil {
|
||||
return nil, fmt.Errorf("migrate: Go migration %d must not be nil", i)
|
||||
}
|
||||
}
|
||||
root, err := fs.Sub(files, dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("migrate: directory %q: %w", dir, err)
|
||||
}
|
||||
info, err := fs.Stat(root, ".")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("migrate: directory %q: %w", dir, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("migrate: directory %q: not a directory", dir)
|
||||
}
|
||||
opts := []goose.ProviderOption{
|
||||
goose.WithDisableGlobalRegistry(true),
|
||||
goose.WithGoMigrations(migrations...),
|
||||
goose.WithSlog(logger),
|
||||
goose.WithVerbose(true),
|
||||
}
|
||||
if dialect == goose.DialectPostgres {
|
||||
locker, err := lock.NewPostgresSessionLocker()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("migrate: postgres locker: %w", err)
|
||||
}
|
||||
opts = append(opts, goose.WithSessionLocker(locker))
|
||||
}
|
||||
provider, err := goose.NewProvider(dialect, db, root, opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("migrate: new provider: %w", err)
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
@@ -1,457 +0,0 @@
|
||||
package migrate_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/pressly/goose/v3"
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"go.auvem.com/corestack/migrate"
|
||||
)
|
||||
|
||||
// invariant: embedded fixtures are read-only; no test mutates shared migration state.
|
||||
//
|
||||
//go:embed testdata
|
||||
var fixtures embed.FS
|
||||
|
||||
func TestSQLite(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
run func(*testing.T, *sql.DB, goose.Dialect)
|
||||
}{
|
||||
{"flow", testFlow}, {"sql-only", testSQLOnly}, {"failure", testFailure},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "migrate.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
test.run(t, db, goose.DialectSQLite3)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgres(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
run func(*testing.T, *sql.DB, goose.Dialect)
|
||||
}{
|
||||
{"flow", testFlow}, {"sql-only", testSQLOnly}, {"failure", testFailure},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) { test.run(t, postgresDatabase(t)(), goose.DialectPostgres) })
|
||||
}
|
||||
}
|
||||
|
||||
func testFlow(t *testing.T, db *sql.DB, dialect goose.Dialect) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||
defer cancel()
|
||||
var logs bytes.Buffer
|
||||
goMigration := goose.NewGoMigration(20261027120000,
|
||||
&goose.GoFunc{RunTx: func(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, "INSERT INTO migration_events (version) VALUES (20261027120000)")
|
||||
return err
|
||||
}},
|
||||
&goose.GoFunc{RunTx: func(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, "DELETE FROM migration_events WHERE version = 20261027120000")
|
||||
return err
|
||||
}})
|
||||
p, err := migrate.New(db, dialect, fixtures, "testdata/migrations", slog.New(slog.NewJSONHandler(&logs, nil)), goMigration)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
versions := []int64{7, 20261026093000, 20261027114500, 20261027120000}
|
||||
// Registering on one provider must not leak into another provider in the same process.
|
||||
other := newProvider(t, db, dialect, "testdata/migrations")
|
||||
if len(other.ListSources()) != 3 {
|
||||
t.Fatal("Go migration leaked to another provider")
|
||||
}
|
||||
assertState(t, ctx, p, 0, versions, 0)
|
||||
results, err := p.UpTo(ctx, versions[1])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertResults(t, results, versions[:2])
|
||||
assertState(t, ctx, p, versions[1], versions, 2)
|
||||
results, err = p.Up(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertResults(t, results, versions[2:])
|
||||
assertState(t, ctx, p, versions[3], versions, 4)
|
||||
assertEvents(t, ctx, db, versions)
|
||||
results, err = p.Up(ctx)
|
||||
if err != nil || len(results) != 0 {
|
||||
t.Fatalf("idempotent Up = %v, %v", results, err)
|
||||
}
|
||||
results, err = p.DownTo(ctx, versions[1])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertResults(t, results, []int64{versions[3], versions[2]})
|
||||
assertState(t, ctx, p, versions[1], versions, 2)
|
||||
assertEvents(t, ctx, db, versions[:2])
|
||||
results, err = p.DownTo(ctx, versions[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertResults(t, results, []int64{versions[1]})
|
||||
assertState(t, ctx, p, versions[0], versions, 1)
|
||||
assertEvents(t, ctx, db, versions[:1])
|
||||
if _, err := p.DownTo(ctx, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertState(t, ctx, p, 0, versions, 0)
|
||||
results, err = p.Up(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertResults(t, results, versions)
|
||||
assertEvents(t, ctx, db, versions)
|
||||
if !strings.Contains(logs.String(), `"logger":"goose"`) || !strings.Contains(logs.String(), `"version":20261027114500`) {
|
||||
t.Fatalf("missing structured migration logs: %s", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
func testSQLOnly(t *testing.T, db *sql.DB, dialect goose.Dialect) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||
defer cancel()
|
||||
for _, test := range []struct {
|
||||
dir string
|
||||
versions []int64
|
||||
}{
|
||||
{"testdata/numeric", []int64{7, 10}},
|
||||
{"testdata/migrations", []int64{7, 20261026093000, 20261027114500}},
|
||||
} {
|
||||
p := newProvider(t, db, dialect, test.dir)
|
||||
assertState(t, ctx, p, 0, test.versions, 0)
|
||||
results, err := p.UpTo(ctx, test.versions[1]-1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertResults(t, results, test.versions[:1])
|
||||
assertState(t, ctx, p, test.versions[0], test.versions, 1)
|
||||
results, err = p.Up(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertResults(t, results, test.versions[1:])
|
||||
assertState(t, ctx, p, test.versions[len(test.versions)-1], test.versions, len(test.versions))
|
||||
assertEvents(t, ctx, db, test.versions)
|
||||
results, err = p.DownTo(ctx, test.versions[1]-1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantDown := slices.Clone(test.versions[1:])
|
||||
slices.Reverse(wantDown)
|
||||
assertResults(t, results, wantDown)
|
||||
assertState(t, ctx, p, test.versions[0], test.versions, 1)
|
||||
if _, err := p.DownTo(ctx, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertState(t, ctx, p, 0, test.versions, 0)
|
||||
}
|
||||
}
|
||||
|
||||
func testFailure(t *testing.T, db *sql.DB, dialect goose.Dialect) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||
defer cancel()
|
||||
p := newProvider(t, db, dialect, "testdata/failure")
|
||||
_, err := p.Up(ctx)
|
||||
var partial *goose.PartialError
|
||||
if !errors.As(err, &partial) || partial.Failed.Source.Version != 20261027114500 ||
|
||||
!strings.Contains(err.Error(), "missing_migration_table") || !strings.Contains(err.Error(), "20261027114500") {
|
||||
t.Fatalf("expected clear failed timestamp migration error, got %v", err)
|
||||
}
|
||||
assertResults(t, partial.Applied, []int64{7, 20261026093000})
|
||||
assertState(t, ctx, p, 20261026093000, []int64{7, 20261026093000, 20261027114500}, 2)
|
||||
assertEvents(t, ctx, db, []int64{7, 20261026093000})
|
||||
// A second attempt must fail at the same version without repeating committed work.
|
||||
if _, err := p.Up(ctx); err == nil {
|
||||
t.Fatal("retry unexpectedly succeeded")
|
||||
}
|
||||
assertEvents(t, ctx, db, []int64{7, 20261026093000})
|
||||
}
|
||||
|
||||
func newProvider(t *testing.T, db *sql.DB, dialect goose.Dialect, dir string, migrations ...*goose.Migration) *goose.Provider {
|
||||
t.Helper()
|
||||
p, err := migrate.New(db, dialect, fixtures, dir, slog.New(slog.NewTextHandler(io.Discard, nil)), migrations...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func assertState(t *testing.T, ctx context.Context, p *goose.Provider, current int64, versions []int64, applied int) {
|
||||
t.Helper()
|
||||
var sources []int64
|
||||
for _, source := range p.ListSources() {
|
||||
sources = append(sources, source.Version)
|
||||
}
|
||||
if !slices.Equal(sources, versions) {
|
||||
t.Fatalf("ListSources = %v; want %v", sources, versions)
|
||||
}
|
||||
got, err := p.GetDBVersion(ctx)
|
||||
if err != nil || got != current {
|
||||
t.Fatalf("GetDBVersion = %d, %v; want %d", got, err, current)
|
||||
}
|
||||
got, latest, err := p.GetVersions(ctx)
|
||||
if err != nil || got != current || latest != versions[len(versions)-1] {
|
||||
t.Fatalf("GetVersions = %d, %d, %v", got, latest, err)
|
||||
}
|
||||
pending, err := p.HasPending(ctx)
|
||||
if err != nil || pending != (applied < len(versions)) {
|
||||
t.Fatalf("HasPending = %v, %v", pending, err)
|
||||
}
|
||||
status, err := p.Status(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(status) != len(versions) {
|
||||
t.Fatalf("Status length = %d; want %d", len(status), len(versions))
|
||||
}
|
||||
for i, s := range status {
|
||||
want := goose.StatePending
|
||||
if i < applied {
|
||||
want = goose.StateApplied
|
||||
}
|
||||
if s.Source.Version != versions[i] || s.State != want {
|
||||
t.Fatalf("Status[%d] = %+v, version %d; want %s, %d", i, s, s.Source.Version, want, versions[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertResults(t *testing.T, results []*goose.MigrationResult, want []int64) {
|
||||
t.Helper()
|
||||
got := make([]int64, 0, len(results))
|
||||
for _, r := range results {
|
||||
got = append(got, r.Source.Version)
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("applied versions = %v; want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEvents(t *testing.T, ctx context.Context, db *sql.DB, want []int64) {
|
||||
t.Helper()
|
||||
rows, err := db.QueryContext(ctx, "SELECT version FROM migration_events ORDER BY version")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var got []int64
|
||||
for rows.Next() {
|
||||
var v int64
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = append(got, v)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("events = %v; want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// postgresDatabase owns only its random schema, never the supplied database.
|
||||
func postgresDatabase(t *testing.T) func() *sql.DB {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("MIGRATE_POSTGRES_DSN")
|
||||
if dsn == "" {
|
||||
if os.Getenv("CI") != "" {
|
||||
t.Fatal("MIGRATE_POSTGRES_DSN is required in CI")
|
||||
}
|
||||
t.Skip("set MIGRATE_POSTGRES_DSN to run Postgres integration tests")
|
||||
}
|
||||
config, err := pgx.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin := stdlib.OpenDB(*config)
|
||||
t.Cleanup(func() {
|
||||
if err := admin.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
schema := "migrate_" + strings.ToLower(rand.Text())
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||
defer cancel()
|
||||
if _, err := admin.ExecContext(ctx, "CREATE SCHEMA "+schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
if _, err := admin.ExecContext(ctx, "DROP SCHEMA "+schema+" CASCADE"); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
config.RuntimeParams["search_path"] = schema
|
||||
return func() *sql.DB {
|
||||
db := stdlib.OpenDB(*config)
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
return db
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresConcurrentUp(t *testing.T) {
|
||||
open := postgresDatabase(t)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||
defer cancel()
|
||||
const workers = 4
|
||||
providers := make([]*goose.Provider, workers)
|
||||
for i := range providers {
|
||||
// Each provider owns a distinct pool and Go migration instance, like separate daemons.
|
||||
pause := goose.NewGoMigration(3, &goose.GoFunc{RunTx: func(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, "SELECT pg_sleep(0.2)")
|
||||
return err
|
||||
}}, nil)
|
||||
providers[i] = newProvider(t, open(), goose.DialectPostgres, "testdata/migrations", pause)
|
||||
}
|
||||
type outcome struct {
|
||||
results []*goose.MigrationResult
|
||||
err error
|
||||
}
|
||||
start := make(chan struct{})
|
||||
done := make(chan outcome, workers)
|
||||
for _, p := range providers {
|
||||
go func() {
|
||||
select {
|
||||
case <-start:
|
||||
case <-ctx.Done():
|
||||
done <- outcome{err: ctx.Err()}
|
||||
return
|
||||
}
|
||||
results, err := p.Up(ctx)
|
||||
done <- outcome{results, err}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
var applied []int64
|
||||
for range workers {
|
||||
result := <-done
|
||||
if result.err != nil {
|
||||
t.Error(result.err)
|
||||
}
|
||||
for _, r := range result.results {
|
||||
applied = append(applied, r.Source.Version)
|
||||
}
|
||||
}
|
||||
slices.Sort(applied)
|
||||
if want := []int64{3, 7, 20261026093000, 20261027114500}; !slices.Equal(applied, want) {
|
||||
t.Fatalf("concurrent Up applied = %v; want exactly once each: %v", applied, want)
|
||||
}
|
||||
assertEvents(t, ctx, open(), []int64{7, 20261026093000, 20261027114500})
|
||||
for _, p := range providers {
|
||||
assertState(t, ctx, p, 20261027114500, applied, len(applied))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConstructor(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
db *sql.DB
|
||||
dialect goose.Dialect
|
||||
dir string
|
||||
logger *slog.Logger
|
||||
migrations []*goose.Migration
|
||||
contains string
|
||||
}{
|
||||
{name: "nil database", dialect: goose.DialectSQLite3, dir: "testdata/migrations", logger: logger, contains: "db must not be nil"},
|
||||
{name: "nil logger", db: db, dialect: goose.DialectSQLite3, dir: "testdata/migrations", contains: "slog logger must not be nil"},
|
||||
{name: "invalid directory", db: db, dialect: goose.DialectSQLite3, dir: "../migrations", logger: logger, contains: "directory"},
|
||||
{name: "missing directory", db: db, dialect: goose.DialectSQLite3, dir: "missing", logger: logger, contains: "migrate:"},
|
||||
{name: "invalid dialect", db: db, dialect: "unknown", dir: "testdata/migrations", logger: logger, contains: "dialect"},
|
||||
{name: "nil migration", db: db, dialect: goose.DialectSQLite3, dir: "testdata/migrations", logger: logger, migrations: []*goose.Migration{nil}, contains: "must not be nil"},
|
||||
{name: "duplicate version", db: db, dialect: goose.DialectSQLite3, dir: "testdata/migrations", logger: logger, migrations: []*goose.Migration{goose.NewGoMigration(7, nil, nil)}, contains: "7"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
p, err := migrate.New(test.db, test.dialect, fixtures, test.dir, test.logger, test.migrations...)
|
||||
if err == nil || p != nil || !strings.Contains(err.Error(), test.contains) {
|
||||
t.Fatalf("New = %v, %v; want error containing %q", p, err, test.contains)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Construction and validation are offline, even when the database is closed.
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := migrate.New(db, goose.DialectSQLite3, fixtures, "testdata/migrations", logger); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConstructorDirectory(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Directory validation and Go-only construction must remain offline.
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
for _, test := range []struct {
|
||||
dir string
|
||||
wantErr string
|
||||
}{
|
||||
{dir: "missing", wantErr: "directory"},
|
||||
{dir: "testdata/migrations/00007_events.sql", wantErr: "not a directory"},
|
||||
{dir: "."},
|
||||
{dir: "testdata"},
|
||||
} {
|
||||
t.Run(test.dir, func(t *testing.T) {
|
||||
p, err := migrate.New(db, goose.DialectSQLite3, fixtures, test.dir, logger, goose.NewGoMigration(10, nil, nil))
|
||||
if test.wantErr != "" {
|
||||
if err == nil || p != nil || !strings.Contains(err.Error(), test.wantErr) || !strings.Contains(err.Error(), test.dir) {
|
||||
t.Fatalf("New = %v, %v; want directory error containing %q and %q", p, err, test.wantErr, test.dir)
|
||||
}
|
||||
if test.dir == "missing" && !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("New error = %v; want fs.ErrNotExist", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sources := p.ListSources(); len(sources) != 1 || sources[0].Version != 10 {
|
||||
t.Fatalf("ListSources = %v; want Go migration 10 only", sources)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE migration_events (version BIGINT PRIMARY KEY);
|
||||
INSERT INTO migration_events (version) VALUES (7);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE migration_events;
|
||||
@@ -1,5 +0,0 @@
|
||||
-- +goose Up
|
||||
INSERT INTO migration_events (version) VALUES (20261026093000);
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM migration_events WHERE version = 20261026093000;
|
||||
@@ -1,6 +0,0 @@
|
||||
-- +goose Up
|
||||
INSERT INTO migration_events (version) VALUES (20261027114500);
|
||||
INSERT INTO missing_migration_table (version) VALUES (20261027114500);
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM migration_events WHERE version = 20261027114500;
|
||||
@@ -1,6 +0,0 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE migration_events (version BIGINT PRIMARY KEY);
|
||||
INSERT INTO migration_events (version) VALUES (7);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE migration_events;
|
||||
@@ -1,5 +0,0 @@
|
||||
-- +goose Up
|
||||
INSERT INTO migration_events (version) VALUES (20261026093000);
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM migration_events WHERE version = 20261026093000;
|
||||
@@ -1,5 +0,0 @@
|
||||
-- +goose Up
|
||||
INSERT INTO migration_events (version) VALUES (20261027114500);
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM migration_events WHERE version = 20261027114500;
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE migration_events (version BIGINT PRIMARY KEY);
|
||||
INSERT INTO migration_events (version) VALUES (7);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE migration_events;
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
-- +goose Up
|
||||
INSERT INTO migration_events (version) VALUES (10);
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM migration_events WHERE version = 10;
|
||||
Reference in new issue
Block a user