Repository navigation
Replies: 3 comments
Revision: the open questions answeredThis revision answers both open questions:
Each section below replaces the section of the same name in the post, except The drain, which changes in one bullet. Background, Changes to SummaryEvery runtime package was about to ship its own copy of the same App. This proposal replaces them with one entry point in the root package:
func main() {
humus.Main(app.Init, humus.WithTelemetry(buildTelemetry))
}func TestExport(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
tel := humustest.NewTelemetry()
_, status := humustest.Start(t, app.Init,
humustest.WithApp(humus.WithTelemetry(tel.Builder())),
humustest.WithEnv(map[string]string{"EXPORT_BUCKET": "nightly"}),
)
require.Zero(t, (<-status).Code())
})
}Proposed APIpackage humus // github.com/z5labs/humus
// Main runs the App that init and opts describe on the OS process, and exits
// the process with the App's status. It never returns.
//
// The App starts from Main's defaults, and opts change them in order:
//
// - Host: OSHost().
// - Telemetry: a Builder that calls telemetry.New with
// telemetry.DefaultSettings(), the Host's Dial and the Process's stdout.
// - ShutdownGracePeriod: 20s.
// - Drain: none.
//
// HUMUS_SHUTDOWN_GRACE_PERIOD and HUMUS_SHUTDOWN_DRAIN_DELAY override the
// grace period and the drain, as for any App. Main is bedrock.Main of that
// App. It panics if init is nil.
func Main(init func(ctx context.Context, s *Setup) ([]Service, error), opts ...Option)
// Option changes fields of the App that Main or humustest builds.
type Option func(*App)
// WithHost sets App.Host.
func WithHost(h Host) Option
// WithTelemetry sets App.Telemetry.
func WithTelemetry(b telemetry.Builder) Option
// WithShutdownGracePeriod sets App.ShutdownGracePeriod, the grace period
// when HUMUS_SHUTDOWN_GRACE_PERIOD is not set.
func WithShutdownGracePeriod(d time.Duration) Option
// WithDrain sets App.Drain, the drain when HUMUS_SHUTDOWN_DRAIN_DELAY is not
// set.
func WithDrain(d Drain) Option
// Drain waits, once the App has started shutting down, until it is safe to
// stop the Services: until load balancers have stopped sending the App new
// work. Run calls it after Serve's context is done and before it calls
// Shutdown on the Services, with the Setup that Init was given.
//
// Run calls it only when a Service is still serving once shutdown starts.
// ctx is done once every Service's Serve has returned, because nothing is
// left to drain, and a Drain returns when it is done or when ctx is done,
// whichever comes first. Run waits for it to return, and the grace period
// starts after it. An error it returns is joined into the App's cause and
// the App exits ExitFailure, and Shutdown is still called on every Service.
type Drain func(ctx context.Context, s *Setup) error
// DrainDelay returns a Drain that waits d, or until ctx is done. It panics
// if d is negative.
func DrainDelay(d time.Duration) Drain
type App struct {
// Host, ShutdownGracePeriod, Telemetry and Init are unchanged.
// Drain is the App's drain when the environment does not set
// HUMUS_SHUTDOWN_DRAIN_DELAY. A nil Drain doesn't wait. It replaces
// DrainDelay.
Drain Drain
}package humustest // github.com/z5labs/humus/humustest
// Option changes the App that Start runs, or the Process it runs on.
type Option func(*startOptions)
// WithApp applies opts to the App, in order, after Start's defaults.
func WithApp(opts ...humus.Option) Option
// WithEnv, WithArgs and WithStdin set the Process's environment, its
// arguments after the program name, and its standard input. They work like
// bedrocktest's options of the same names.
func WithEnv(values map[string]string) Option
func WithArgs(args ...string) Option
func WithStdin(r io.Reader) Option
// Start runs the App that humus.Main would run for init with
// bedrocktest.Start, on a new Process. The App has a new Host of this
// package's in place of humus.OSHost(), and opts change the App and the
// Process: humus.WithHost, passed with WithApp, replaces that Host. Call
// Start inside the testing/synctest bubble that runs the App, since the Host
// is built there. It panics if init is nil.
func Start(t testing.TB, init func(ctx context.Context, s *humus.Setup) ([]humus.Service, error), opts ...Option) (*bedrocktest.Process, <-chan bedrock.ExitStatus)
A service, end to endA runtime package exports its Service, and the end user's func main() {
humus.Main(Init, humus.WithTelemetry(buildTelemetry))
}
func Init(ctx context.Context, s *humus.Setup) ([]humus.Service, error) {
bucket, err := config.Env(s.Process.Environment, "EXPORT_BUCKET").Read(ctx)
if err != nil {
return nil, err
}
pool, err := openPool(ctx, s) // through s.Host.Dial
if err != nil {
return nil, err
}
s.OnStop(func(context.Context) error { pool.Close(); return nil })
e := &exporter{pool: pool, bucket: bucket}
return []humus.Service{job.NewService(s, e.Run)}, nil
}Its test passes the test's own Host and telemetry recorder, and the environment func TestInit_exports_every_row(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
host := humustest.NewHost(t)
tel := humustest.NewTelemetry()
_, status := humustest.Start(t, Init,
humustest.WithApp(humus.WithHost(host.Host()), humus.WithTelemetry(tel.Builder())),
humustest.WithEnv(map[string]string{"EXPORT_BUCKET": "nightly"}),
)
st := <-status
require.Zero(t, st.Code())
require.Equal(t, "job", tel.Spans()[0].Name())
})
}A runtime package whose Services need a drain builds a package httpserver
// Defaults sets an HTTP service's defaults over humus.Main's: a 5s drain
// and a 10s grace period.
func Defaults() humus.Option {
return func(a *humus.App) {
a.Drain = humus.DrainDelay(5 * time.Second)
a.ShutdownGracePeriod = 10 * time.Second
}
}
// Main is humus.Main with Defaults applied before opts.
func Main(init func(ctx context.Context, s *humus.Setup) ([]humus.Service, error), opts ...humus.Option) {
humus.Main(init, append([]humus.Option{Defaults()}, opts...)...)
}The end user's The drainUnchanged, except that
The defaults
The worst case is 20s of grace period and 5s of telemetry shutdown: 25s. For each runtime:
Design decisions
TestingThese tests are sketches and have not been run. They use humustest's The drain ends once nothing is serving: func TestApp_the_drain_ends_once_every_Serve_has_returned(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
start := time.Now()
var shutdownAt time.Duration
svc := &fakeService{
serve: func(ctx context.Context) error {
<-ctx.Done()
time.Sleep(2 * time.Second) // finishing the work in flight
return nil
},
shutdown: func(context.Context) error {
shutdownAt = time.Since(start)
return nil
},
}
p, status := humustest.Start(t,
func(context.Context, *humus.Setup) ([]humus.Service, error) {
return []humus.Service{svc}, nil
},
humustest.WithApp(humus.WithDrain(humus.DrainDelay(5*time.Second))),
)
synctest.Wait()
p.Signal(syscall.SIGTERM)
require.Zero(t, (<-status).Code())
require.Equal(t, 2*time.Second, shutdownAt) // not the drain's 5s
})
}A Service that keeps serving until func TestApp_a_Service_that_serves_until_Shutdown_gets_the_whole_drain(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
start := time.Now()
var shutdownAt time.Duration
stopped := make(chan struct{})
svc := &fakeService{
serve: func(context.Context) error { <-stopped; return nil },
shutdown: func(context.Context) error {
shutdownAt = time.Since(start)
close(stopped)
return nil
},
}
p, status := humustest.Start(t,
func(context.Context, *humus.Setup) ([]humus.Service, error) {
return []humus.Service{svc}, nil
},
humustest.WithApp(humus.WithDrain(humus.DrainDelay(5*time.Second))),
)
synctest.Wait()
p.Signal(syscall.SIGTERM)
require.Zero(t, (<-status).Code())
require.Equal(t, 5*time.Second, shutdownAt)
})
}The tests also show the following:
Changes to the other proposalsThis proposal changes the four runtime proposals, which the charter's revision 5 moves to wave 3. No correction is posted on their discussions. Each one revises itself when it is taken up again:
The charter's revision 5 is updated to match this revision. This proposal's own stories change the root package, humustest, their tests, and the docs pages for the lifecycle, configuration, telemetry and testing. Migration from today
Open questionsNone. |
Revision 2: the Drain's contract, test packages, and what the integration foundA prototype of the first revision ran both of its sketch tests unchanged. No-App versions of the four wave-3 runtime packages, built on it with an end user's service and whole-App tests for each, found what the first revision left undecided. This revision decides it:
One correction to the first revision: answer 2 said "As the post found, not every runtime needs a drain." The post found that the early end already made a drain free for a Service that stops on Each section below replaces the section of the same name, in the first revision where it has one and in the post otherwise. Background, Out of scope and References are unchanged, apart from the wording fixes at the end. SummaryEvery runtime package was about to ship its own copy of the same App. This proposal replaces them with one entry point in the root package:
func main() {
humus.Main(app.Init, humus.WithTelemetry(buildTelemetry))
}func TestExport(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
tel := humustest.NewTelemetry()
_, status := humustest.Start(t, app.Init,
humustest.WithApp(humus.WithTelemetry(tel.Builder())),
humustest.WithEnv(map[string]string{"EXPORT_BUCKET": "nightly"}),
)
require.Zero(t, (<-status).Code())
})
}Proposed APIUnchanged, apart from these declarations. In the root package, package humus // github.com/z5labs/humus
// Main runs the App that init and opts describe on the OS process, and exits
// the process with the App's status. It never returns.
//
// The App starts from Main's defaults, and opts change them in order:
//
// - Host: OSHost().
// - Telemetry: a Builder that calls telemetry.New with
// telemetry.DefaultSettings(), the Host's Dial and the Process's stdout.
// - ShutdownGracePeriod: 20s.
// - Drain: none.
//
// Init is init, and is set before opts are applied, so an Option sees it
// and may wrap it, for example to add a Service.
//
// HUMUS_SHUTDOWN_GRACE_PERIOD and HUMUS_SHUTDOWN_DRAIN_DELAY override the
// grace period and the drain, as for any App. Main applies every option
// before it runs anything, and is then bedrock.Main of the App. It panics if
// init or one of opts is nil.
func Main(init func(ctx context.Context, s *Setup) ([]Service, error), opts ...Option)
// Drain waits, once the App has started shutting down, until it is safe to
// stop the Services: until load balancers have stopped sending the App new
// work. Run calls it after Serve's context is done and before it calls
// Shutdown on the Services, with the Setup that Init was given.
//
// Run calls it when, as shutdown starts, it hasn't yet received every
// Serve's result. ctx is done once every Service's Serve has returned,
// because nothing is left to drain, and it may be done as soon as Run calls
// the Drain, so a Drain checks it before it acts. A Drain returns when it is done or when
// ctx is done, whichever comes first, and returns nil when ctx is done: one
// that returns ctx.Err() then fails the App.
//
// ShutdownCause(ctx) says why the shutdown started. A Drain that waits on
// something outside the App, such as a load balancer deregistering the
// pod, returns nil when the cause wraps ErrServiceReturned: no signal came,
// so nothing outside is draining the App.
//
// Run waits for the Drain to return, and the grace period starts after it.
// An error it returns is joined into the App's cause and the App exits
// ExitFailure, and Shutdown is still called on every Service. ctx keeps the
// values of Run's context but is not cancelled with it.
type Drain func(ctx context.Context, s *Setup) error
// ErrServiceReturned is in a shutdown's cause when a Service's Serve
// returned before any signal came, and so started the shutdown.
var ErrServiceReturned = errors.New("humus: a Service returned before any signal")
// ShutdownCause returns why the App started shutting down, when ctx is the
// context Run gives a Drain or one derived from it, and nil for any other
// context. After a signal, it is the cause the signal gave the Process's
// NotifyContext. When a Service's Serve returned first, it wraps
// ErrServiceReturned and what Serve returned, if that was an error. When
// the context passed to Run was cancelled, it is that context's cause.
func ShutdownCause(ctx context.Context) error
// DrainDelay returns a Drain that waits d, or until ctx is done, and returns
// nil either way. It doesn't look at ShutdownCause. It panics if d is
// negative.
func DrainDelay(d time.Duration) DrainIn humustest, the docs of package humustest // github.com/z5labs/humus/humustest
// WithEnv, WithArgs and WithStdin set the Process's environment, its
// arguments after the program name, and its standard input. They work like
// bedrocktest's options of the same names: if one is given more than once,
// the last one wins. A helper that sets a base environment and lets a test
// add to it merges the maps itself.
func WithEnv(values map[string]string) Option
func WithArgs(args ...string) Option
func WithStdin(r io.Reader) Option
// Start runs the App that humus.Main would run for init with
// bedrocktest.Start, on a new Process. The App has a new Host of this
// package's in place of humus.OSHost(), and opts change the App and the
// Process: humus.WithHost, passed with WithApp, replaces that Host. The
// Host and Init are set before opts are applied, so an Option given with
// WithApp sees both and may wrap them. Call Start inside the
// testing/synctest bubble that runs the App, since the Host is built there.
// It panics if init or one of opts is nil.
func Start(t testing.TB, init func(ctx context.Context, s *humus.Setup) ([]humus.Service, error), opts ...Option) (*bedrocktest.Process, <-chan bedrock.ExitStatus)A service, end to endUnchanged up to the HTTP sketch. The paragraph after the sketch becomes: The end user's An end user whose The drainThe drain is the pause between shutdown starting and
The defaults
The worst case is 20s of grace period and 5s of telemetry shutdown: 25s. For each runtime:
Changes to
|
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
Part of the humus re-evaluation; see Proposal: humus on the bedrock process model. The charter's revision 5 makes this proposal wave 2 on its own, and moves the four runtime proposals to wave 3 so that they can build on it. It builds on Proposal: the humus App lifecycle, Proposal: OpenTelemetry without globals and Proposal: humustest.
Summary
Every runtime package was about to ship its own copy of the same App. This proposal replaces all four with one entry point in the root package:
humus.Main(init, opts...)runs an App on the OS process. OnlyInitis required. The Host, the telemetryBuilder, the grace period and the drain all have defaults, and functional options override them.humus.Drain, which ends early once no Service is still serving. So a job pays nothing for the drain that an API needs, and the runtime packages need no defaults of their own. They export Services, and the end user'sInitreturns them.humustest.Start(t, init, opts...)runs the App thatMainwould run, on a test Host, with the same options.humustest.NewAppbuilds that App for a test that drives bedrocktest itself.Background
Every wave-2 runtime proposal wraps
humus.Appthe same way. Each one has an App withHost,Telemetryand anInit, whoseRunbuilds ahumus.Appwith the runtime's defaults:httpserver.Apphumusgrpc.Appjob.Appkafka.AppTheir
Inits differ only in what they return. A process that runs two of them, such as a job next to a metrics server, can't use either App and falls back tohumus.App.The drain delay is why the defaults differ.
humus.App.Runsleeps the drain delay whenever an App with Services shuts down, including when a Service returned on its own (app.go). An API needs that sleep: Kubernetes removes a terminating pod from a Service's endpoints asynchronously, and an API that stops accepting connections at once refuses requests that are still being routed to it. A job or a queue consumer has nothing routed to it, and with a 5s drain delay every job would wait 5s after it finished. So each runtime picked its own drain delay, and an App type to carry it.mainand every whole-App test repeat the same literal. Todaymainwritesbedrock.Main(humus.App{Host: humus.OSHost(), ShutdownGracePeriod: …, DrainDelay: …, Telemetry: buildTelemetry, Init: Init}), and a test writes the same literal with a humustest Host, passed tobedrocktest.Start. Nothing butInitdiffers from one service to the next.Kubernetes' budget is 30s. A pod gets SIGTERM, then SIGKILL after
terminationGracePeriodSeconds, which defaults to 30s. The drain, the grace period and telemetry's shutdown (telemetry.DefaultSettings().ShutdownTimeoutis 5s) all have to fit inside it.Proposed API
humus.Appstays as it is, apart fromDrain. It is what anOptionchanges, whathumustest.NewAppreturns, and whathumustest.TestServiceruns. Amainthat wants no defaults at all can still writebedrock.Main(humus.App{…}).A service, end to end
A runtime package exports its Service, and the end user's
Initreturns it. A job, withjob.NewServiceas Proposal: jobs proposes it:An API's
Initreturns its server's Service in the same way, with whatever constructor the HTTP server foundation settles on. A process that runs a job next to a metrics server returns both, with nothing else to change.Its test passes the test's own Host and telemetry recorder as options, and runs with the same defaults as production:
A test that needs the environment, arguments or stdin passes the App to bedrocktest itself:
The drain
The drain is the pause between shutdown starting and
Shutdownbeing called on the Services. It becomes a function, so that what it waits for is the end user's choice, and so that it can stop waiting once nothing is left to drain.It ends early once nothing is serving. Run cancels the Drain's context once every Service's
Servehas returned. A Service that stops onServe's context, such as a job, a queue consumer or the health Service, returns at once or once its work in flight is done. A Service that keeps serving untilShutdown, such as an HTTP or gRPC server, doesn't return until after the drain. So:This generalises a rule the lifecycle already has: an
Initthat returns no Services ends the App without a drain, because there is nothing to drain.DrainDelay(d)is the default kind of drain. It waitsd, or until nothing is serving.Main's default isDrainDelay(5 * time.Second).A Drain of the end user's own waits for something else. For example, one that polls a load balancer's API until the pod's target is draining, and goes through
s.Host.Dialso that it runs on a test Host too:It gets the Setup, not what
Initbuilt. The Drain is chosen inmain, beforeInitruns. Work at shutdown that needsInit's dependencies, such as deregistering from service discovery, belongs in a Service that stops onServe's context. That Service sees shutdown start at the same moment, and the Drain then waits for whatever has to follow it, such as clients' cached lookups expiring.HUMUS_SHUTDOWN_DRAIN_DELAYreplaces the Drain. When it is set, Run usesDrainDelayof its value in place ofApp.Drain, whatever that is. It is still atime.ParseDurationstring that must not be negative, and a bad value still exits 78 beforeInit. An operator can always replace a Drain that misbehaves, from the same manifest that setsterminationGracePeriodSeconds.The defaults
MainHostOSHost()Mainruns only inmain. A test builds its App with humustest, which uses a test Host.Telemetrytelemetry.NewwithDefaultSettings(), writing to the Process's stdoutBuilder.ShutdownGracePeriodShutdownand every OnStop function share it.DrainDrainDelay(5 * time.Second)The worst case is 5s of drain, 15s of grace period and 5s of telemetry shutdown: 25s, inside Kubernetes' default 30s, with a margin for the process to exit. For each runtime:
Servereturns, at most 5s, and the grace period follows. That is the 20s that the jobs and Kafka proposals chose, and it costs nothing when the work stops at once.Changes to
Runhumus.App.Runchanges in two of its steps. Everything else in it is unchanged.HUMUS_SHUTDOWN_DRAIN_DELAY. When the variable is set, the drain isDrainDelayof its value. When it is not, the drain isApp.Drain, and a nilApp.Draindoesn't wait.Serveis still running, Run calls the Drain with the Setup, and with a context that keeps the values of Run's context, is not cancelled with it, and is done once everyServehas returned. It collects theServeresults that arrive in the meantime, waits for the Drain to return, and then shuts the Services down within the grace period. A Drain error is logged at ERROR and joined into the cause asdrain:and the error.As today, neither a cancelled root context nor a second signal cuts the drain short: the second signal takes its default action and ends the process.
Design decisions
Optionisfunc(*App).humustest.NewAppapplies the same options thatmainpasses toMain, so a test runs the App production runs. An option changes an exported field, so an end user can write their own as a one-line function literal.Main, not inApp.humus.App's fields keep their meaning: a zeroShutdownGracePeriodfails startup, and a nilDraindoesn't wait, as the configuration proposal's "zero means zero" asks.Mainis the one place with opinions. BothMainand humustest read the defaults from an internal package, so neither has to export them.OSHost()only inMain.humus.Apprequires a Host so that a test can't run on the real host by mistake.Mainnever runs in a test, andhumustest.NewAppstarts from a test Host, so the default can't leak into one.DefaultSettings(). A service that forgets itsBuilderlogs to stdout and exports no traces. It doesn't fail. A team that exports anything already writes aBuilderto name the service and point it at a collector.HUMUS_SHUTDOWN_DRAIN_DELAYreplaces it without a code change.Shutdownis still called, because the Services have to stop either way.Startreturns bedrocktest'sProcessand status channel, so a test that sends signals or reads stdout doesn't import bedrocktest. A test that sets the environment, arguments or stdin passesNewApp's App tobedrocktest.Startwith bedrocktest's options. See open question 1.humustest.NewAppkeepsMain's telemetry default. A whole-App test that passes noBuildersees onp.Stdout()the JSON lines production would write. The default builds no exporter that dials out, and runs insidesynctest. A test that asserts on spans or metrics passeshumustest.NewTelemetry().Builder().humus.NewApp.humustest.NewAppcovers tests, andMaincoversmain. A program that wants an App value of its own writes thehumus.Appliteral.Testing
These tests are sketches and have not been run. They use humustest's
Hostand bedrocktest's process, insidesynctest, so every duration below is fake time.The drain ends once nothing is serving:
A Service that keeps serving until
Shutdowngets the whole drain:The tests also show the following:
HUMUS_SHUTDOWN_DRAIN_DELAY=1sreplaces a custom Drain, which is never called, andHUMUS_SHUTDOWN_DRAIN_DELAY=-1sexits 78 beforeInit.drain:and the error in the cause, logged at ERROR, andShutdownis still called on every Service.Initwas given.App.Draindoesn't wait:Shutdownis called at the same fake instant as the signal.DrainDelaywith a negative duration panics, and so doMainandhumustest.NewAppwith a nilinit.humustest.NewAppwith no options returns an App with a 15s grace period, a 5sDrainDelay, a humustest Host andMain's telemetry default, and its first log line appears onp.Stdout()as JSON.humustest.NewAppwithhumus.WithHostandhumus.WithTelemetryuses the Host andBuilderit was given.Mainruns hasOSHost(), the 15s grace period and the 5sDrainDelay, checked by an internal test of the functionMainbuilds it with, sinceMainitself exits the process.Changes to the other proposals
This proposal changes the four runtime proposals, which the charter's revision 5 moves to wave 3. No correction is posted on their discussions. Each one revises itself when it is taken up again:
Initreturns it. The work their Apps did inInit, such as building the HTTP Service from theSettingsand handlerInitreturned, moves into the end user'sInit. Their default durations are replaced byMain's.job.Apprequires aBuilder, is answered byMain's telemetry default.Main, its options and the Services the end user'sInitreturns.This proposal's own stories change the root package, humustest, their tests, and the docs pages for the lifecycle, configuration, telemetry and testing.
Migration from today
bedrock.Main(humus.App{Host: humus.OSHost(), ShutdownGracePeriod: 30 * time.Second, DrainDelay: 5 * time.Second, Telemetry: buildTelemetry, Init: Init})becomeshumus.Main(Init, humus.WithTelemetry(buildTelemetry), humus.WithShutdownGracePeriod(30*time.Second)).App.DrainDelay: dbecomesApp.Drain: humus.DrainDelay(d), orhumus.WithDrain(humus.DrainDelay(d))withMain.bedrocktest.Start(t, humus.App{Host: host.Host(), Telemetry: tel.Builder(), ShutdownGracePeriod: …, Init: Init})becomeshumustest.Start(t, Init, humus.WithHost(host.Host()), humus.WithTelemetry(tel.Builder())), which also runs with production's grace period and drain.Serve's context now shuts down as soon as they have returned, instead of after the whole drain delay.HUMUS_SHUTDOWN_DRAIN_DELAYkeeps its name and format. When it is set, it now replaces any Drain, not just a duration.Open questions
humustest.Starttake bedrocktest's options? As proposed,Starttakes humus's options only, and a test that sets the environment, arguments or stdin writesbedrocktest.Start(t, humustest.NewApp(t, Init, opts...), bedrocktest.WithEnv(env)). Settings tests do that often. The alternative is an option type of humustest's own, covering both humus's options and the process's, which re-wraps bedrocktest's options and goes against humustest building on bedrocktest without wrapping it. Recommendation: as proposed.Out of scope
Mainpasses the process's arguments toInitinSetup.Process, as today. Parsing them is the end user's.Mainand the end user's Services.References
DefaultSettings(), and the choice each runtime App was to make about itsBuilderMainandbedrocktest.StartAll reactions