Repository navigation
Replies: 2 comments
Revision: the open questions answered, on humus.MainThe charter's revision 5 moved this proposal to wave 3, behind Proposal: humus.Main, which has landed. This revision applies humus.Main and answers the four open questions:
Each section below replaces the section of the same name in the post. Background, Out of scope and References are unchanged, apart from the wording fixes at the end. SummaryThe charter lists
func main() {
humus.Main(app.Init, humus.WithTelemetry(buildTelemetry))
}
Proposed API
package job // github.com/z5labs/humus/job
// NewService returns a Service that runs run once, when its Serve is called.
// run's context is done when the App starts shutting down or when Shutdown is
// called, whichever comes first, and carries the span of the run.
//
// The span's parent is the trace context that s.Telemetry.Propagator
// extracts from s.Process.Environment, with each of the propagator's keys
// read as OpenTelemetry's environment-variable carriers name it: traceparent
// as TRACEPARENT. When the environment carries none, or a malformed one, the
// span is the root of a new trace.
//
// NewService panics if run is nil.
func NewService(s *humus.Setup, run func(ctx context.Context) error) *ServiceThat is the whole package: one Service and its constructor, no App, no The runThe run is a plain 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 // exit 78
}
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, fs: s.Host.FS}
return []humus.Service{job.NewService(s, e.Run)}, nil
}Exit statuses
The fourth row is the run's own decision: it finished, or it chose to treat stopping early as done. A run that stops early because its context is done returns an error, as almost all Go code already does with Which signal a job honoursThe lifecycle gives a Service two signals:
Telemetry
The carrier is unexported: // envCarrier is a propagation.TextMapCarrier over a Process's environment.
// It reads each key under its name as an environment-variable carrier, and
// writes nothing.
type envCarrier struct{ env bedrock.Environment }
// Get returns the value of key's variable, or "" when it is not set.
func (c envCarrier) Get(key string) string
// Set does nothing: Extract never calls it, and the environment is read-only.
func (envCarrier) Set(key, value string) {}
// Keys returns nil, since a bedrock.Environment can't list its variables.
// The specification lets it return only names that are already normalized,
// and W3C trace context and baggage never call it.
func (envCarrier) Keys() []string { return nil }The defaultsThis section replaces The App's defaults.
An operator changes them with Running more than the jobA process that runs a job next to other Services returns them all from the end user's Which Proposal: exposing health probes (wave 4) decides whether a job pod exposes probes, in terms of Design decisionsUnchanged, except that these bullets replace the ones with the same heading:
These bullets are new:
TestingThese tests are sketches and have not been run. A whole App runs under // runs returns an Init whose only Service is a job that runs run.
func runs(run func(context.Context) error) func(context.Context, *humus.Setup) ([]humus.Service, error) {
return func(_ context.Context, s *humus.Setup) ([]humus.Service, error) {
return []humus.Service{job.NewService(s, run)}, nil
}
}
func TestService_a_run_that_returns_nil_exits_0(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
tel := humustest.NewTelemetry()
_, status := humustest.Start(t, runs(func(context.Context) error {
time.Sleep(time.Minute) // fake time: returns at once
return nil
}), humustest.WithApp(humus.WithTelemetry(tel.Builder())))
st := <-status
require.Zero(t, st.Code())
require.NoError(t, st.Cause())
spans := tel.Spans()
require.Len(t, spans, 1)
require.Equal(t, "job", spans[0].Name())
require.False(t, spans[0].Parent().IsValid()) // no TRACEPARENT: a new trace
require.Equal(t, time.Minute, spans[0].EndTime().Sub(spans[0].StartTime()))
})
}
func TestService_SIGTERM_interrupts_the_run_and_exits_1(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
tel := humustest.NewTelemetry()
p, status := humustest.Start(t, runs(func(ctx context.Context) error {
<-ctx.Done()
return context.Cause(ctx)
}), humustest.WithApp(humus.WithTelemetry(tel.Builder())))
synctest.Wait() // the run is blocked on its context
p.Signal(syscall.SIGTERM)
st := <-status
require.Equal(t, humus.ExitFailure, st.Code())
require.ErrorContains(t, st.Cause(), "serve: terminated signal received")
require.Equal(t, codes.Error, tel.Spans()[0].Status().Code)
})
}
func TestService_joins_the_trace_in_TRACEPARENT(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
tel := humustest.NewTelemetry()
var schedule string
_, status := humustest.Start(t, runs(func(ctx context.Context) error {
schedule = baggage.FromContext(ctx).Member("schedule").Value()
return nil
}),
humustest.WithApp(humus.WithTelemetry(tel.Builder())),
humustest.WithEnv(map[string]string{
"TRACEPARENT": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
"BAGGAGE": "schedule=nightly",
}),
)
require.Zero(t, (<-status).Code())
span := tel.Spans()[0]
require.Equal(t, "4bf92f3577b34da6a3ce929d0e0e4736", span.SpanContext().TraceID().String())
require.Equal(t, "00f067aa0ba902b7", span.Parent().SpanID().String())
require.True(t, span.Parent().IsRemote())
require.Equal(t, trace.SpanKindInternal, span.SpanKind())
require.Equal(t, "nightly", schedule)
})
}
func TestService_meets_the_Service_contract(t *testing.T) {
humustest.TestService(t, func(t *testing.T, s *humus.Setup) humus.Service {
return job.NewService(s, func(ctx context.Context) error {
<-ctx.Done()
return ctx.Err()
})
})
}That exercises both stop paths: the " The tests also show the following:
Migration from today
Wording fixes to the post
Open questionsNone. |
Closing: jobs have landedThe revision's stories are done:
The revision left no open questions. What it hands on belongs to Proposal: exposing health probes, in wave 4, which decides whether a job pod exposes probes. |
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, wave 2. It builds on Proposal: the humus App lifecycle, Proposal: configuration for humus apps, Proposal: OpenTelemetry without globals and Proposal: humustest. It depends on no other wave-2 proposal. Proposal: exposing health probes (wave 3) decides whether a job's App ever exposes probes, and adds that to this package if it does.
Summary
The charter lists
jobas "keep or drop". This proposes keeping it, smaller than a framework and bigger than nothing:job.NewServiceturns afunc(context.Context) errorinto ahumus.Servicethat runs it once, stops it on either shutdown signal, and records one span around the run.job.Appis abedrock.Appbuilt onhumus.App, with a job's defaults: no drain delay, a grace period that fits Kubernetes' default, and no health Service.nilexits 0. A run that fails, or that a signal interrupts before it finishes, exits 1, so Kubernetes never records an interrupted job as a success.The package has no
Settings, reads nothing and opens nothing. It is a leaf of the root package, as the lifecycle proposal sets out for every runtime package.Background
jobis a 36-line wrapper.Handler,HandlerFuncand anAppwhoseRun(context.Context) errorcalls the handler (job/job.go#L10-L36). It has the v0.21.0 runtime shape that bedrock removed, and it sets up no telemetry, as the charter notes. Nothing else in it survives the process model.Servethat returnsnilends the App with exit 0 and no cause, and one that returns an error ends it with exit 1. So the question for this proposal is what a package adds on top of that.humustest.TestServiceholds every Service to five rules:ServereturnsnilafterShutdown;Shutdownwith a done context returns at once;Shutdownis safe beforeServe, afterServehas returned and twice; aServeafterShutdownreturns; and no goroutine outlivesServe. A job also has to tell "Shutdownstopped me" apart from "a signal interrupted me", because the two need different exit statuses (see below). That is about 60 lines that every job service would otherwise copy.Succeeded, and the Job completes. Any other code is a failure, counted againstbackoffLimitand retried underrestartPolicy: OnFailureor by a new pod underNever. A pod that is evicted, preempted or deleted gets SIGTERM, and then SIGKILL afterterminationGracePeriodSeconds(30s by default).podFailurePolicycan act on exit codes (onExitCodes) and on theDisruptionTargetpod condition. So the exit code of an interrupted run decides whether the work is done again.humus.App.Runreturns 0, with the signal as the cause, when a signal stopped the App and every Service shut down cleanly. That is right for an API. For a job it is right only if the run had finished.Proposed API
That is the whole package: one Service, one App, no named function type, no
Settings.The run
The run is a plain
func(ctx context.Context) error. A struct with aRunmethod passes its method value,j.Run, so the package needs neither today'sHandlerinterface norHandlerFunc.Initbuilds the run's dependencies and closes over them:Exit statuses
job.Appadds no exit codes. The lifecycle's table applies, and the run decides which row:nilserve:and the run's errorserve: context canceled, or the signal when the run returnscontext.Cause(ctx)nil*humus.GracePeriodErrorInitfails reading a setting*config.ErrorThe fourth row is the run's own decision: it finished, or it chose to treat stopping early as done. A run that stops early because its context is done returns an error, as almost all Go code already does with
ctx.Err().Which signal a job honours
The lifecycle gives a Service two signals:
Serve's context, done as soon as shutdown starts, andShutdown, called after the drain delay. A job stops on the first, as a queue consumer does.Serve's context is the run's parent. A signal cancels the run at once, and the run has the whole grace period to stop. Withjob.App's drain delay of 0,Shutdownis called straight after and waits for it.Shutdownalso cancels the run. In an App, the run's context is always done beforeShutdownis called, so this only matters when a Service is driven directly, asTestServicedoes, or when a future caller callsShutdownfirst.Servereturnsnilin that case, because the Service contract says a Service thatShutdownstopped returnsnil.Serve's result. The run's context is made withcontext.WithCancelCause, andShutdowncancels it with an unexported cause.context.Causereports the first cause, so after a signal it is the signal, andServereturns the run's error; after a bareShutdownit isShutdown's, andServereturnsnilwhen the error is a cancellation. Any other error from the run is returned either way.Telemetry
NewServicetakes the tracers.Telemetry.TracerProvider.Tracer("github.com/z5labs/humus/job"). Nothing reads OTel's globals.job, of kind internal. It starts whenServecalls the run and ends when the run returns. When the run returns an error, the span's status isErrorwith the error's text as its description. A run thatShutdownstopped, whichServereports asnil, still has the error status: the span records what the run returned.InfoContext(ctx, …), carries itstrace_id. Withtelemetry.DefaultSettings(), traces are not exported but spans still get real IDs, so one job's console logs share a trace ID even without a collector.Serveat ERROR, and "serving", "shutting down" and "stopped" at INFO. A record fromjobwould repeat them.The App's defaults
job.AppDrainDelay0ShutdownGracePeriod20sterminationGracePeriodSecondsof 30s, with room for the telemetry shutdown that follows it (telemetry.DefaultSettings().ShutdownTimeoutis 5s).TelemetryThe defaults are not fields of
job.App. An operator changes them withHUMUS_SHUTDOWN_GRACE_PERIODandHUMUS_SHUTDOWN_DRAIN_DELAY, whichhumus.App.Runreads. A team that wants a different default in code useshumus.Appwithjob.NewService, which is five more lines.Running more than the job
job.Appruns one job and nothing else. A process that runs a job next to other Services, such as a metrics endpoint or a sidecar-like consumer, useshumus.Appdirectly and returnsjob.NewService(s, run)among its Services. Any Service returning ends the App, so the job ending ends the process, as it should.Proposal: exposing health probes (wave 3) decides whether a job's App ever exposes probes on an HTTP server of its own. If it does, it adds a field to
job.App, which doesn't changeInit's shape or break an existingjob.Appliteral.Design decisions
Serve, theShutdownthat stops the run and waits, the rulesTestServicechecks, the span and the exit-status rule for an interrupted run. The alternative, recorded here, is to dropjoband document a twenty-line Service in the lifecycle docs. It is rejected because the interrupted-run rule is easy to get wrong, and getting it wrong marks unfinished work as done.Servethat returns an error already exits 1. An operator who doesn't want disruptions counted againstbackoffLimitusespodFailurePolicywith theDisruptionTargetcondition andaction: Ignore.onExitCodes: {operator: In, values: [78]}andaction: FailJob. The docs page shows that rule.Servedoesn't wrap it, and the lifecycle prefixesserve:. A run that wants the signal in its cause returnscontext.Cause(ctx)instead ofctx.Err().Serve's goroutine.NewServicestarts no goroutine, so no goroutine of the package outlivesServe. Goroutines the run starts are the end user's.func(context.Context) erroris the shape of the run, and a method value adapts any type. An interface with one method would add a name and aFuncadapter for nothing.NewServicepanics on a nil run, and returns no error. The package has no settings to check, so a nil run is the only misuse, and it is a programming error, as a nil function is forSetup.OnStop.Settings. The package opens nothing and has no tunable. The grace period and drain delay belong to the lifecycle. As forhealth, there is noSettings, noDefaultSettingsand no checks.backoffLimit. A run that wants to retry a step in-process uses whatever it likes inside the run, such as bedrock'sretrymodule (v0.24.0).jobdoesn't wrap it, so retries stay visible in the end user's code.telemetryand the OTel trace API, all of which the core module already requires. No third-party dependency is added.Testing
These tests are sketches and have not been run.
A whole job App runs under
synctest, with bedrocktest's process, humustest'sHostand humustest's telemetry recorder:job.Servicepasseshumustest.TestService.TestServiceneeds a Service that runs until it is stopped, so the test's run blocks on its context:That exercises both stop paths: the "
ServereturnsnilafterShutdown" rule relies on a bareShutdownbeing reported asnil, and the "afterServehas returned on its own" rule cancelsServe's context first.The tests also show the following:
Errorwith the error's text.nilexits 0, with the signal as the cause.*humus.GracePeriodError.HUMUS_SHUTDOWN_GRACE_PERIOD=5soverrides the 20s default, and the App's drain delay is 0:Shutdownis called on the job at the same fake instant as the signal.Initfailing with a*config.Errorexits 78 and never runs the job.jobspan, and a record it logs with that context carries thejobspan's trace ID.Servecalled twice runs the job once.Shutdownwith a done context returns an error that wraps it, andServestill returns once the run does.NewServicewith a nil run panics.Migration from today
job.NewApp(h), which bedrock ran as aRuntime, becomesbedrock.Main(job.App{Host: humus.OSHost(), Telemetry: buildTelemetry, Init: Init}), withInitreturning the run.job.Handlerandjob.HandlerFuncgo away. A type withHandle(ctx) errorpasses its method value,h.Handle, as the run.mainor in the Builder before, are built inInitand released withs.OnStop.jobspan, froms.Telemetry.nil.backoffLimitandpodFailurePolicyfor exit 78 and for disruptions, and running a job beside other Services withhumus.App. It is one of this proposal's stories.Open questions
job.Apprequire atelemetry.Builder? The telemetry proposal leaves each runtime App to choose between requiring one and defaulting totelemetry.NewwithDefaultSettings(). The default would name every jobunknown_service, andNewwrites to the process's stdout, which theBuilderis given butjob.Appwould have to pass on itself. Recommendation: required, as forhumus.App, somainnames the service.HUMUS_SHUTDOWN_GRACE_PERIOD, and should raiseterminationGracePeriodSecondswith it.podFailurePolicytreat interruptions differently from real failures by exit code.DisruptionTargetalready covers the evictions and preemptions that cause most interruptions. Recommendation: no new code.TRACEPARENTas an environment-variable carrier, so a job started by another traced process can join its trace. humus reads no variables, so it would be a field the end user fills, such as a parent context onjob.App. Recommendation: not now. Add it when a service needs it.Out of scope
jobruns one run per process.concurrencyPolicy.job.Servicevalues withhumus.App.References
BuilderTestServiceand the telemetry recorderjob/job.gobackoffLimit, and pod terminationretrymoduleAll reactions