Repository navigation
Replies: 3 comments
|
Feedback on the lifecycle. Each point says what should change here and what it changes in the other wave-1 proposals. 1. Remove
|
Revision: the review and the answers to the open questionsThis revision applies the review and the answers to the nine open questions. Each section below replaces the section of the same name in the post. Background, Out of scope and References are unchanged and still apply. Two integrations are owned by other proposals, and this one doesn't specify them:
Decisions
The proposal has no open questions left. SummaryEvery humus app type does the same work around its own:
This proposes one small lifecycle, exported from the root package, that every app type is built on and that can run several services in one process. Setting up telemetry is added to it by Proposal: OpenTelemetry without globals. func main() {
bedrock.Main(humus.App{
Host: humus.OSHost(),
DrainDelay: 5 * time.Second, // humus has no default
Init: app.Init,
})
}Most users never write this. They use a runtime package's App (REST, gRPC, a job, a queue), which is built on Proposed APIpackage humus
// Service is a long-running part of an App: an HTTP server, a gRPC server,
// a queue consumer or a job.
type Service interface {
// Serve runs the Service until Shutdown stops it, until it fails, or, for
// a Service that finishes on its own such as a job, until it is done. It
// returns nil when Shutdown stopped it or it finished.
//
// ctx is done as soon as the App starts shutting down, before the drain
// delay. A Service may stop on it, as a job or a queue consumer does, or
// keep serving until Shutdown, as an HTTP, gRPC or health Service does.
Serve(ctx context.Context) error
// Shutdown stops the Service gracefully. The App calls it on every
// Service after the drain delay, including one whose Serve has already
// returned. If ctx is done before the Service has stopped, Shutdown
// stops it at once and returns an error that wraps ctx's error.
Shutdown(ctx context.Context) error
}
// Host is what humus hands an App beyond its process: the network and the
// filesystem. Every field is required.
type Host struct {
Listen func(network, address string) (net.Listener, error) // like net.Listen
Dial func(ctx context.Context, network, address string) (net.Conn, error) // like (*net.Dialer).DialContext
FS FS
}
// FS is the filesystem of a Host. Names are OS paths.
type FS interface {
Open(name string) (*os.File, error)
OpenFile(name string, flag int, perm os.FileMode) (*os.File, error)
CreateTemp(dir, pattern string) (*os.File, error)
MkdirTemp(dir, pattern string) (string, error)
Mkdir(name string, perm os.FileMode) error
MkdirAll(name string, perm os.FileMode) error
Remove(name string) error
RemoveAll(name string) error
Rename(oldname, newname string) error
Stat(name string) (os.FileInfo, error)
ReadDir(name string) ([]os.DirEntry, error)
// DirFS returns an fs.FS view of dir, for fs.WalkDir, fs.Glob,
// template.ParseFS and the other std helpers.
DirFS(dir string) fs.FS
}
// OSHost returns the Host of the current OS process.
func OSHost() Host
// App is a bedrock.App that runs Services with humus's lifecycle. Host and
// Init are required.
type App struct {
Host Host // OSHost() in main; humustest's Host in tests
// DrainDelay is the pause between shutdown starting and Shutdown being
// called on the Services. Zero means no pause. humus reads no variable
// for it and has no default; a runtime package's App sets its own.
DrainDelay time.Duration
Init func(ctx context.Context, s *Setup) ([]Service, error)
}
func (a App) Run(ctx context.Context, p bedrock.Process) bedrock.ExitStatus
// Setup is what Init is given.
type Setup struct {
Process bedrock.Process
Host Host
// unexported: OnStop functions
}
// OnStop registers f to run once every Service has stopped, or once Init
// has failed. Functions run in reverse order of registration, like deferred
// calls, within the grace period.
func (s *Setup) OnStop(f func(context.Context) error)
const (
ExitFailure = 1 // Init, a Service, its shutdown or an OnStop function failed
ExitConfig = 78 // a setting is missing or malformed: EX_CONFIG from sysexits.h
)
// GracePeriodError is the cause of an App whose Services or OnStop
// functions were still running when the grace period ran out. The App
// exits ExitFailure.
type GracePeriodError struct {
GracePeriod time.Duration // the grace period that ran out
Err error // every error the shutdown returned, joined
}
func (e *GracePeriodError) Error() string
func (e *GracePeriodError) Unwrap() error
// Settings is the lifecycle's configuration; see Proposal: configuration for humus apps.
type Settings struct {
ShutdownGracePeriod time.Duration
}
func ReadSettings(ctx context.Context, env bedrock.Environment) (Settings, error)Proposal: OpenTelemetry without globals adds The method set of Each runtime package provides Services, whose constructors take a What Run does, in order
Every failure is the cause of the status, and is written to Settings
The root reads no variable for the drain delay. A runtime package's App may read Exit codes
shoppingcart under this proposalfunc Init(ctx context.Context, s *humus.Setup) ([]humus.Service, error) {
url, err := config.Env(s.Process.Environment, "DATABASE_URL").Read(ctx)
if err != nil {
return nil, err // exit 78: init: config DATABASE_URL: not found
}
cfg, err := pgxpool.ParseConfig(url)
if err != nil {
return nil, err
}
cfg.ConnConfig.DialFunc = s.Host.Dial
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, err
}
s.OnStop(func(context.Context) error { pool.Close(); return nil })
api, err := rest.NewService(ctx, s, app.API(database.New(pool))) // Proposal: REST endpoint DSL and OpenAPI
if err != nil {
return nil, err
}
return []humus.Service{api}, nil
}The readiness monitor on the pool moves to the health Service; see Proposal: health monitors and probes. humus can only document one leak in this example: Design decisions
TestingThe lifecycle runs whole under func TestApp_ShutdownIsBoundedByTheGracePeriod(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// stuck serves until shutdown starts, then never finishes shutting down.
stuck := serviceFuncs{
serve: func(ctx context.Context) error {
<-ctx.Done()
return nil
},
shutdown: func(ctx context.Context) error {
<-ctx.Done()
return fmt.Errorf("stuck shutdown: %w", ctx.Err())
},
}
app := humus.App{
Host: humus.OSHost(), // the fake Service never uses it
Init: func(ctx context.Context, s *humus.Setup) ([]humus.Service, error) {
return []humus.Service{stuck}, nil
},
}
p := bedrocktest.NewProcess(t, bedrocktest.WithEnv(map[string]string{"HUMUS_SHUTDOWN_GRACE_PERIOD": "2s"}))
status := make(chan bedrock.ExitStatus, 1)
go func() { status <- app.Run(t.Context(), p.Process()) }()
<-p.Notified(syscall.SIGTERM)
begin := time.Now()
p.Signal(syscall.SIGTERM)
st := <-status
require.Equal(t, humus.ExitFailure, st.Code())
gerr, ok := errors.AsType[*humus.GracePeriodError](st.Cause())
require.True(t, ok)
require.Equal(t, 2*time.Second, gerr.GracePeriod)
require.ErrorIs(t, gerr, context.DeadlineExceeded)
require.Equal(t, 2*time.Second, time.Since(begin))
require.Contains(t, p.Stderr(), "stuck shutdown: context deadline exceeded")
})
}The lifecycle's tests show the following:
One thing to know when writing such tests: Migration from today
Open questionsNone. The answers are in the Decisions table above. Changes to the other proposalsThese follow from the decisions above, and each is posted as a revision on its own proposal.
PrototypeThe prototype in the post predates this revision. Its shutdown sequence still holds, but it has |
Correction: decision 8 is reversed by #633Decision 8 in the revision said that
The last item under "Changes to the other proposals → humustest (#633)" changes the same way. This proposal's stories are unaffected. #637 and #638 already test with bedrocktest and fake Services. The whole-App tests on humustest's |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Note
The Summary, Proposed API, design decisions, testing, migration, open questions and prototype in this post are superseded by the revision below. It applies the review and answers all nine open questions.
App.NameandSetup.Loggerare removed, andHost.ReadFilebecomes a filesystem on*os.File. Health leaves the lifecycle, the drain delay becomes anAppfield with no humus default, and running past the grace period gets its own cause type. Telemetry's integration with the lifecycle belongs to #632, and humustest's to #633. The rest of this post is unchanged and kept for context.Note
Part of the humus re-evaluation; see Proposal: humus on the bedrock process model. It depends on the charter and blocks every app-type proposal. It takes over the scope of #624. It builds on the other wave-1 proposals: configuration (#631), telemetry (#632), humustest (#633) and health (#634).
Summary
Every humus app type does the same work around its own:
This proposes one small lifecycle, exported from the root package, that every app type plugs into and that can run several services in one process:
A tested prototype is at the end.
Background
rest.Runwraps a Builder stack inDefaultRunner,RecoverPanicsandNotifyOnSignalforos.Interrupt,os.KillandSIGTERM(rest/rest.go#L201-L224).os.Killcan't be caught, so listing it does nothing.job.Appandqueue.AppareRun(context.Context) error(job/job.go,queue/queue.go).internal/httpservershuts down withShutdown(context.Background()), which can hang (internal/httpserver/server.go#L40).internal/grpcservercallsGracefulStopwith no bound at all (internal/grpcserver/server.go#L37).grpchas had no lifecycle since story(issue-484): rest migrate to bedrock v0210 #485.mainreadsDATABASE_URLwithos.Getenvand opens a pgx pool before callingrest.Run(example/rest/shoppingcart/main.go#L30). Under the process model nothing reads the real environment, and a test of the whole service can't swap that pool out.Mainprints nothing. TheExitStatuscause is for tests, so the App writes anything a user should see top.Stderr.p.NotifyContext, and bedrocktest drives them in tests.example/httpserveris the reference for a grace period and for returningExitWithCause(0, context.Cause(ctx)).Proposed API
Each app-type package provides Services rather than an App of its own. Their constructors take a
*humus.Setup, for examplerest.NewService(ctx, s, api), and are proposed in the wave-2 discussions.healthis imported by the root package, so it takes explicit parts instead.What Run does, in order
Settingsfromp.Environment. A malformed value exits 78.App.Telemetry. A malformedOTEL_*value exits 78, and any other failure exits 1.ctx, stop := p.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM), beforeInit, so a signal during a slowInit(for example, retrying a database connection) cancels it.Init(ctx, setup). If it fails, run theOnStopfunctions registered so far, shut telemetry down, and exit 78 when a*config.Erroris in the error chain, or 1 otherwise.Serveon every Service concurrently, and mark the App ready.stop(). From now on a second interrupt or SIGTERM takes its default action and ends the process at once.ShutdownDrainDelay.Shutdownon every Service concurrently, withinShutdownGracePeriod.OnStopfunctions in reverse order, within the same grace period.Every failure is written to
p.Stderr, one line per error, and is also the cause of the status.Settings
HUMUS_SHUTDOWN_GRACE_PERIOD10sShutdownand theOnStopfunctionsHUMUS_SHUTDOWN_DRAIN_DELAY0sShutdownstartingExit codes
Init, a Service, its shutdown (including running past the grace period) or anOnStopfunction failedInitshoppingcart under this proposal
humus can only document one leak in this example:
pgxpool.ParseConfigreadsPGHOST,PGPASSWORDand the otherPG*variables from the real environment, andPGPASSFILEfrom disk. A dependency that reads the real process behind an App's back belongs on the "ambient leaks" list in humus's documentation, as it does in bedrock's.Design decisions
Appwould have each package re-implement it, and would make composing them impossible.Serve/Shutdownis the shape ofhttp.Serverand of gRPC'sServe/GracefulStop. A Service returning fromServeends the App, so a job is just a Service that returns.Initowns the user's dependencies.OnStopworks liket.Cleanup: functions run in reverse order, and also run whenInitfails partway through.Hostis the second boundary. It holds what bedrock leaves to the framework and a test must be able to swap out: listening, dialling and reading files.Dialis included for the OTLP exporter, for user clients such as pgx'sDialFunc, and for the later outbound clients. The clock comes fromsynctestand randomness fromcryptotest, so neither needs a field.mainstates every seam it uses.os.Killis dropped because it can't be caught. Callingstop()as shutdown starts means a second signal ends the process at once, which bedrock documents forNotifyContext.net/httpalready recovers handler panics, while grpc-go does not. The HTTP and gRPC proposals decide it.errors.AsType[*config.Error]classifies config errors thatInitreturns, too.Testing
Every humus App runs whole under
synctest, on bedrocktest'sProcessand humustest'sHost. This test is from the prototype below and passes under-race:The prototype also shows the following:
context.DeadlineExceededin the cause.HUMUS_SHUTDOWN_GRACE_PERIODexits 78 with a cause that names it, and never callsInit.DATABASE_URLread inInitexits 78 and runsOnStopin reverse order.One thing to know when writing such tests:
synctestreports a deadlock when a goroutine the App started outlives the test body. One example is a handler that ignores its request context after the server force-closes the connection. That turnssynctestinto a leak detector for Services, at the cost of handlers having to honourr.Context().Migration from today
rest.Run(ctx, opts...)becomeshumus.Appplus a REST Service (Proposal: REST endpoint DSL and OpenAPI).job.Appandjob.Handlerbecome a job Service (Proposal: jobs).queue.Appandqueue.Runtimebecome a queue Service (Proposal: queue processing and the Kafka runtime).humus.Logger(name)becomess.Telemetry.Logger(name).mainbecomesbedrock.Main(humus.App{...}), with the pool built inInitand closed inOnStop.Open questions
Setup,HostandService, or something else?RuntimeandContextare avoided on purpose, because bedrock retired both.0sright, or should it be a few seconds so Kubernetes can propagate endpoint removal?ShutdownandOnStoptogether, or separate budgets?nilon its own, such as a consumer whose queue ended. Exit 0, or treat it as an error?Initthat parsess.Process.Argswith bedrock's idiom: how does a usage error map to exit 2?Host.Dial. In scope now, or with the outbound clients?humustest.Start(t, app, p). Does it belong in humustest, or in bedrocktest, since it only needsbedrock.App?Hostboundary. Should humus's forbidigo list bannet.Listen,net.Dialandos.ReadFileoutsideOSHost? That's stricter than bedrock, whose list covers only the process boundary. Moved here from the charter.Out of scope
Prototype
The prototype below runs against bedrock at
131f577. It omits telemetry and health, which have their own proposals, and its logger is a JSON handler onp.Stdout.go test -race -count=3passes, covering 97.5% of the lifecycle.humus.gohumus_test.goReferences
doc.go: the boundary, panics and "Beyond the boundary"example/httpserverAll reactions