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 answers the four open questions, applies humus.Main, and takes in the charter's revision 7. The open questions:
What humus.Main and the charter change:
Each section below replaces the section of the same name in the post. Background and References are unchanged. Summaryhumus can't run a gRPC service today.
func main() {
grpcserver.Main(app.Init, humus.WithTelemetry(buildTelemetry))
}
grpc-go runs inside a Proposed API// Package grpcserver serves gRPC with humus's lifecycle.
package grpcserver // import "github.com/z5labs/humus/grpc/grpcserver", in the nested module github.com/z5labs/humus/grpc
// Settings configures a Service. Every field is a plain value, and a
// field's zero value means zero, never "use the default". Neither field
// has a default, so the end user sources both.
type Settings struct {
// Addr is the address the Service listens on through the Setup's Host,
// as host:port. It is required: humus imposes no port. An empty host
// listens on every name, and port 0 picks a free port, which
// Service.Addr reports.
//
// Default: none. Valid: host:port, with a port from 0 to 65535.
// Check: CheckAddr.
Addr string
// TLS is the config the Service serves gRPC with. It is required: the
// Service serves nothing in plain text. A client certificate is
// required by setting ClientAuth, as with any tls.Config.
//
// Default: none. Valid: a config with Certificates, GetCertificate or
// GetConfigForClient set, and a MaxVersion of TLS 1.2 or later, or
// none. Check: CheckTLS.
TLS *tls.Config
}
// CheckAddr reports why addr is not a valid Addr, or nil if it is.
func CheckAddr(addr string) error
// CheckTLS reports why c can't serve gRPC, or nil if it can. c must not be
// nil, must have Certificates, GetCertificate or GetConfigForClient set,
// and must allow TLS 1.2 or later, which HTTP/2 requires.
func CheckTLS(c *tls.Config) error
// Service is a humus.Service that serves gRPC over TLS. It is a
// grpc.ServiceRegistrar, so generated code registers services on it:
//
// cartpb.RegisterCartServiceServer(svc, carts)
type Service struct{ /* unexported */ }
// NewService runs CheckAddr and CheckTLS on settings, reporting every bad
// field in one error that names each one, listens on settings.Addr through
// s.Host, and builds the server.
//
// opts come after humus's own options, and humus's transport credentials
// come after opts. An option that grpc-go keeps one value of, such as
// grpc.MaxRecvMsgSize, replaces humus's. An option that chains, such as
// grpc.ChainUnaryInterceptor, is added inside humus's. grpc.Creds in opts
// has no effect, so every connection is served with settings.TLS.
//
// The listener is closed by Shutdown, or by an OnStop function NewService
// registers with s, if the App never serves.
func NewService(s *humus.Setup, settings Settings, opts ...grpc.ServerOption) (*Service, error)
// Addr returns the address the Service listens on, with the port the Host
// picked when settings.Addr has port 0.
func (s *Service) Addr() net.Addr
// RegisterService, GetServiceInfo and Serve are unchanged.
// Shutdown ends every streaming RPC: it cancels each stream handler's
// context, and a stream whose handler then returns nil or a context error
// ends with codes.Unavailable. It stops the server gracefully: it stops
// accepting connections, sends every client a GOAWAY, and waits for the
// RPCs in flight. If ctx is done first, it closes every connection,
// cancels every RPC, waits for every handler to return, and returns an
// error that wraps ctx's error.
func (s *Service) Shutdown(ctx context.Context) error
// Defaults sets a gRPC service's defaults over humus.Main's: a drain of
// humus.DrainDelay(5 * time.Second) and a 15s grace period. The Host and
// the telemetry Builder stay humus.Main's.
func Defaults() humus.Option
// Main is humus.Main with Defaults applied before opts, so the end user's
// options win over the package's. It never returns.
func Main(init func(ctx context.Context, s *humus.Setup) ([]humus.Service, error), opts ...humus.Option)// Package grpcservertest tests gRPC services on a humustest Host.
package grpcservertest // import "github.com/z5labs/humus/grpc/grpcserver/grpcservertest"
// Start is humustest.Start with grpcserver.Defaults applied before opts, so
// a test runs its App with the drain and the grace period that
// grpcserver.Main gives it in production. Options given with
// humustest.WithApp come after the defaults, and win over them.
func Start(t testing.TB, init func(ctx context.Context, s *humus.Setup) ([]humus.Service, error), opts ...humustest.Option) (*bedrocktest.Process, <-chan bedrock.ExitStatus)
// NewClient returns a client of the Service listening on address in h. It
// dials through h, never resolves address, and verifies the Service's
// certificate for address's host against roots. Transport credentials in
// opts replace those. The client is closed when the test ends. It works
// with or without Start.
func NewClient(t testing.TB, h *humustest.Host, address string, roots *x509.CertPool, opts ...grpc.DialOption) *grpc.ClientConn
The end user's sideThe end user sources func main() {
grpcserver.Main(Init, humus.WithTelemetry(buildTelemetry))
}
func Init(ctx context.Context, s *humus.Setup) ([]humus.Service, error) {
env := func(name string) config.Value[string] { return config.Env(s.Process.Environment, name) }
settings, err := config.Struct(func(r *config.Resolver) grpcserver.Settings {
return grpcserver.Settings{
// No .Default: both fields are required, so an unset variable fails as not found.
Addr: r.Get(env("GRPC_ADDR").Validate(grpcserver.CheckAddr)),
// readFile and serverTLS are the end user's own helpers.
TLS: r.Get(env("GRPC_TLS_P12_FILE").Parse(readFile(s.Host.FS)).Parse(serverTLS).Validate(grpcserver.CheckTLS)),
}
}).Read(ctx)
if err != nil {
return nil, err // exit 78, naming every bad variable
}
pool, err := newPool(ctx, s) // closed by an OnStop function
if err != nil {
return nil, err
}
svc, err := grpcserver.NewService(s, settings, grpc.ChainUnaryInterceptor(authenticate))
if err != nil {
return nil, err
}
cartpb.RegisterCartServiceServer(svc, carts.New(pool))
return []humus.Service{svc}, nil
}With no environment, The ServiceUnchanged, except that the paragraphs below replace the ones with the same heading, and Streams at shutdown is new.
Streams at shutdown. The stream interceptor gives every streaming RPC's handler a context of its own, derived from the RPC's, which
resp, err := stream.Recv()
if status.Code(err) == codes.Unavailable {
// The server is going away. Reconnect: the new stream lands on another pod.
}A handler sees the shutdown through its context. A handler that is blocked in The streams are cancelled when Main and its defaultsThis section replaces The App. func Defaults() humus.Option {
return func(a *humus.App) {
a.Drain = humus.DrainDelay(5 * time.Second)
a.ShutdownGracePeriod = 15 * time.Second
}
}
func Main(init func(ctx context.Context, s *humus.Setup) ([]humus.Service, error), opts ...humus.Option) {
humus.Main(init, append([]humus.Option{Defaults()}, opts...)...)
}
Settings
What waves 4 and 5 build on thisThis section replaces The seam for health. Proposal: exposing health probes (wave 4) decides how probes are served over gRPC health v1, and whether a gRPC service exposes them by default. Kubernetes' gRPC probes connect in plain text, and never present a client certificate. A probe over TLS, with readinessProbe:
grpc:
port: 8081 # the health server's port; the end user's services are served over TLS on their own portThe plaintext port answers health checks and nothing else, so the end user's services are never reachable in plain text. The two ports are two Services in one App, which humus already runs. The seam is in this module:
This proposal doesn't decide the answers to a health check, the service names, Proposal: failure injection (wave 5) strips scenarios from what a server receives, before the end user's code sees them. Its seam here is humus's interceptors. otelgrpc's stats handler extracts the trace context and the baggage into the RPC's context before any interceptor runs. Then humus's recovery runs, then the end user's interceptors, then the handler. Stripping belongs after recovery and before the end user's interceptors, inside the Service, so every RPC is covered without the end user adding an interceptor. The wave-5 proposal designs that interceptor and whatever configures it, and its stories add them here. Design decisionsUnchanged, except for the following. These bullets replace "Serve through the drain delay, stop at
These bullets are new:
TestingThese tests are sketches and have not been run. Every test runs inside a // serves returns an Init whose only Service listens on ":9090" over TLS
// with cert, with ts registered.
func serves(cert *humustest.Certificate, ts testpb.TestServiceServer) func(context.Context, *humus.Setup) ([]humus.Service, error) {
return func(_ context.Context, s *humus.Setup) ([]humus.Service, error) {
svc, err := grpcserver.NewService(s, grpcserver.Settings{
Addr: ":9090",
TLS: &tls.Config{Certificates: []tls.Certificate{cert.TLS()}},
})
if err != nil {
return nil, err
}
testpb.RegisterTestServiceServer(svc, ts)
return []humus.Service{svc}, nil
}
}The Service passes the contract every Service passes: func TestService_meets_the_Service_contract(t *testing.T) {
humustest.TestService(t, func(t *testing.T, s *humus.Setup) humus.Service {
cert := humustest.NewCA(t).Issue(t, "carts")
svc, err := grpcserver.NewService(s, grpcserver.Settings{
Addr: ":9090",
TLS: &tls.Config{Certificates: []tls.Certificate{cert.TLS()}},
})
require.NoError(t, err)
testpb.RegisterTestServiceServer(svc, &testServer{})
return svc
})
}Its subtest "Shutdown is safe after Serve has returned on its own" reports SKIP, as it does for an HTTP Service: An RPC in flight when SIGTERM arrives finishes, and its span is recorded: func TestMain_an_RPC_in_flight_finishes_during_shutdown(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ca := humustest.NewCA(t)
h := humustest.NewHost(t)
tel := humustest.NewTelemetry()
ts := &testServer{entered: make(chan struct{}), release: make(chan struct{})}
p, exit := grpcservertest.Start(t, serves(ca.Issue(t, "carts"), ts),
humustest.WithApp(humus.WithHost(h.Host()), humus.WithTelemetry(tel.Builder())),
)
<-h.Listening(":9090")
client := testpb.NewTestServiceClient(grpcservertest.NewClient(t, h, "carts:9090", ca.Pool()))
got := make(chan string, 1)
go func() {
resp, err := client.UnaryCall(t.Context(), &testpb.SimpleRequest{FillUsername: true})
if err != nil {
got <- err.Error()
return
}
got <- resp.GetUsername()
}()
<-ts.entered // the handler is running
p.Signal(syscall.SIGTERM)
time.Sleep(7 * time.Second) // past the 5s drain: Shutdown is waiting for the RPC
close(ts.release)
require.Equal(t, "done", <-got)
st := <-exit
require.Zero(t, st.Code(), st.Cause())
require.Equal(t, "grpc.testing.TestService/UnaryCall", tel.Spans()[0].Name())
})
}A stream that would never end is ended at // subscriber's StreamingOutputCall sends one response, then waits for its
// context, as a subscription does.
type subscriber struct{ testpb.UnimplementedTestServiceServer }
func (subscriber) StreamingOutputCall(_ *testpb.StreamingOutputCallRequest, stream testpb.TestService_StreamingOutputCallServer) error {
if err := stream.Send(&testpb.StreamingOutputCallResponse{}); err != nil {
return err
}
<-stream.Context().Done()
return stream.Context().Err()
}
func TestMain_shutdown_ends_a_stream_with_Unavailable(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ca := humustest.NewCA(t)
h := humustest.NewHost(t)
p, exit := grpcservertest.Start(t, serves(ca.Issue(t, "carts"), subscriber{}),
humustest.WithApp(humus.WithHost(h.Host())),
)
<-h.Listening(":9090")
client := testpb.NewTestServiceClient(grpcservertest.NewClient(t, h, "carts:9090", ca.Pool()))
stream, err := client.StreamingOutputCall(t.Context(), &testpb.StreamingOutputCallRequest{})
require.NoError(t, err)
_, err = stream.Recv()
require.NoError(t, err)
begin := time.Now()
p.Signal(syscall.SIGTERM)
_, err = stream.Recv()
require.Equal(t, codes.Unavailable, status.Code(err), err)
require.Equal(t, 5*time.Second, time.Since(begin)) // the drain, then Shutdown ends the stream
require.Zero(t, (<-exit).Code())
})
}The end user's func TestInit_without_an_address_or_a_certificate_exits_78(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
_, exit := grpcservertest.Start(t, Init)
st := <-exit
require.Equal(t, humus.ExitConfig, st.Code())
require.ErrorContains(t, st.Cause(), "config GRPC_ADDR: not found")
require.ErrorContains(t, st.Cause(), "config GRPC_TLS_P12_FILE: not found")
})
}The post's test of the grace period is unchanged, except that it runs with The tests also show the following:
The package's own tests follow #631's list, except that there is no Migration from todayThese rows replace the ones with the same first column:
Out of scopeThese bullets replace the ones with the same heading, and the last one is new:
Open questionsNone. |
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. It blocks Proposal: exposing health probes (wave 3), which serves gRPC health v1 on the server proposed here.
Summary
humus can't run a gRPC service today.
grpc.NewApibuilds a*grpc.Serverand nothing serves it. This proposes agrpcpackage, in a nested module, with:Servicethat serves a*grpc.Serveron a listener from the App'sHost. It keeps serving through the drain delay. ItsShutdownstops gracefully within the grace period and then stops hard.Appbuilt onhumus.App, for a process that serves gRPC and nothing else. Its defaults are a 5s drain delay and a 15s grace period.Settingswith two fields,AddrandTLS. Everything else about the server is agrpc.ServerOptionthe end user passes.s.Telemetry, through otelgrpc, with no OTel globals.codes.Internal.grpctest.NewClient, a client that dials the testHost, so grpc-go stays out of the core module's graph.The examples import the package as
humusgrpc, because almost every file that uses it also importsgoogle.golang.org/grpc. Open question 1 asks whether to rename it.grpc-go runs inside a
testing/synctestbubble on humustest's in-memory network: its server, its client, otelgrpc and TLS all do. The tests in this proposal come from a prototype that passesgo test -race -count=3, with 90% coverage.Background
Api.grpc.NewApibuilds a*grpc.Serverand registers a health server on it (grpc/api.go#L35-L62).grpc/grpc.go, which ran it, was deleted in story(issue-484): rest migrate to bedrock v0210 #485.internal/grpcserverstill has a runner, but nothing calls it.internal/grpcservershuts down with no bound. It callsGracefulStopwhen its context is done, with no deadline and no fallback toStop(internal/grpcserver/server.go#L29-L46). An RPC that never returns, such as a server stream, holds the process up until it is killed.NewApipassesotel.GetMeterProvider(),otel.GetTracerProvider()andotel.GetTextMapPropagator()to grpc-go'sstats/opentelemetry(grpc/api.go#L42-L53).Api.RegisterServicelooks for ahealth.Monitoron each implementation and wires it intointernal/grpchealth(grpc/api.go#L64-L71).internal/grpchealthlogs through the globalhumus.Logger, and itsWatchpolls every 10 seconds withtime.After(internal/grpchealth/server.go#L78-L105). Proposal: health monitors and probes moved all of this to Proposal: exposing health probes, in wave 3.RegisterServiceafterServe, a duplicate service name, or an implementation of the wrong type callsgrpclog.Fatalf, which callsos.Exit(1)(server.go#L770-L790,grpclog/grpclog.go#L96-L100). The charter's first principle says nothing in humus exits the process.net/httprecovers a handler's panic and fails only that request.Stopdoesn't wait for handlers by default. A handler can keep running afterStopreturns, unless the server is built withgrpc.WaitForHandlers(true). That option is still marked experimental.grpclogreadsGRPC_GO_LOG_SEVERITY_LEVEL,GRPC_GO_LOG_VERBOSITY_LEVELandGRPC_GO_LOG_FORMATTERat init, and writes to the realos.Stderr(grpclog/loggerv2.go#L65-L81).internal/envconfigreads itsGRPC_*variables the same way.grpc.NewClient("carts:9090")uses thednsresolver, which looks the name up withnet.DefaultResolverwhatever dialer the client has. In a synctest bubble, this crashes the test binary withfatal error: send on synctest channel from outside bubble.Proposed API
The end user's side
The end user sources
Settingsin theirInit, as Proposal: configuration for humus apps sets out, registers their services, and returns the Service:The Service
NewServicedoes three things, in order:CheckAddr, and returns every bad field in one error, naming each one. ASettingsbuilt in code is checked as well as one sourced withconfig.settings.Addrwiths.Host.Listen. The listener is opened inInit, so connections that arrive beforeServewait in the backlog, as the lifecycle expects. IfInitfails afterNewService, the App never callsShutdown, soNewServiceregisters anOnStopfunction that closes the listener. Closing an already closed listener is not an error.*grpc.Serverwith humus's options first, and thenopts:grpc.StatsHandler(otelgrpc.NewServerHandler(...))s.Telemetry; see Telemetry belowgrpc.ChainUnaryInterceptorandgrpc.ChainStreamInterceptorwith panic recoverygrpc.Creds(credentials.NewTLS(settings.TLS))settings.TLSis setgrpc.WaitForHandlers(true)Stopwaits for every handler to return, so no handler outlivesShutdownoptsis where everything else goes: the end user's interceptors, keepalive parameters, message size limits, a second stats handler. grpc-go keeps one value of some options and chains others, andNewServicedocuments what that means here:grpc.CredsreplacesSettings.TLS;grpc.WaitForHandlers(false)lets a handler outliveShutdown. That breaks thehumus.Servicecontract, andhumustest.TestServicereports it as a leak.RegisterServicemust be called beforeServe, as with*grpc.Server. grpc-go exits the process on three misuses. humus checks for the same three first and panics instead, with the service's name in the message.OnStoppanics on a nil function in the same way: a programming error fails loudly, in the end user'sInit, without leaving throughos.Exit.Servehonours onlyShutdown, as an HTTP server does. Its context is done when shutdown starts, but the Service keeps accepting connections and RPCs through the drain delay. Kubernetes stops routing new connections to a terminating pod only once the endpoint removal has reached every node, and a connection that arrives in that window is still served.Servereturns nil onceShutdownhas stopped the server.ShutdowncallsGracefulStop. It closes the listener and sends every connection a GOAWAY, so clients open new connections elsewhere, and it waits for the RPCs in flight to finish. gRPC connections are long-lived, and the GOAWAY is what moves a client off a terminating pod. Ifctxis done first,ShutdowncallsStop. That closes every connection, cancels every RPC's context and, withWaitForHandlers, waits for every handler to return. ThenShutdownreturns an error that wrapsctx.Err(), and the App exits 1 with a*humus.GracePeriodError.Shutdownis safe beforeServe, afterServehas returned and twice.The App
App.Runruns ahumus.App:ShutdownTimeoutadds 5s, so the worst case is 25s, inside Kubernetes' defaultterminationGracePeriodSecondsof 30s. An operator changes either withHUMUS_SHUTDOWN_DRAIN_DELAYorHUMUS_SHUTDOWN_GRACE_PERIOD, whichhumus.App.Runalready reads with these as defaults.Telemetryis required. It doesn't default totelemetry.NewwithDefaultSettings(). Those settings name the serviceunknown_service, which no production service wants, and the end user'sBuilderis where the service gets its name. This also keepshumus.App's rule that every field is required, somainstates every seam it uses.Initreturns the*Service, not a slice. The App keeps the Service it was given, so a later proposal can register on it beforeServe, without changing the end user'sInit. A process that runs gRPC next to something else, such as a Kafka consumer or an HTTP server, useshumus.AppandNewServicedirectly. The lifecycle proposal calls that the advanced path.Settings
Addr":9090"CheckAddrTLSnil, which serves plaintext HTTP/2Settingsholds what an operator changes from one deployment to the next: where to listen, and the certificate. How the server behaves, such as its interceptors, keepalive parameters and message size limits, is code, and is passed as agrpc.ServerOption. That keeps one way to set each of them. The end user who wants a variable for one sources it in theirInitand passes the option.Proposal: configuration for humus apps left optional settings to each package. Here, the answer is that
TLSis optional, and nil means plaintext.Addris not optional.CheckAddraccepts whatHost.Listenaccepts: an empty host for every name, and port 0 for a free port. It rejects an address with no port, and a port that is not a number from 0 to 65535.Telemetry
The Service gets everything from
s.Telemetry, and never touches OTel's globals:What it emits, as the prototype records it with
humustest.Telemetry:grpc.testing.TestService/EmptyCall. Its parent comes from the request's metadata, through the SDK's propagator. It has the attributesrpc.system.name,rpc.method,server.portandrpc.response.status_code. Its status isErrorfor the codes OpenTelemetry's RPC conventions count as server errors,Internalamong them.rpc.server.call.duration, a histogram, from otelgrpc's instrumentation scope.github.com/z5labs/humus/grpc. It is emitted with the RPC's context, so it carries the RPC'strace_idandspan_id. It has the attributesrpc.method,exception.messageandexception.stacktrace.The Service logs nothing else. The lifecycle already logs "serving", "shutting down" and "stopped".
Panics
The Service recovers a panic in a handler or an interceptor. The RPC fails with
codes.Internaland the messageinternal error, so the panic's value never reaches the client. The panic is logged at ERROR with its stack trace, and the RPC's span records the error. Other RPCs, and the server, carry on.The interceptors are the outermost ones, inside otelgrpc's stats handler. So the span sees the
Internalstatus, and a panic in one of the end user's interceptors is recovered too.The seam for health
Proposal: exposing health probes, in wave 3, decides how
health.Probesare served over gRPC health v1, and whether this App does that by default. It needs two things from this proposal, and both are here:*Serviceis agrpc.ServiceRegistrar, sogrpc_health_v1.RegisterHealthServer(svc, h)works on it beforeServe.grpc.Appholds the*Servicethe end user'sInitreturns, so the App can register a health server on it and return a health Service next to it.Servecontext is done when shutdown starts, and that is when it turns not ready. This Service keeps serving through the drain delay, so a gRPC health check during the drain delay is answered, with not ready. grpc-go's own health server hasShutdown(), which sets every status toNOT_SERVING, if wave 3 uses it.This proposal doesn't decide the answers to a health check, the service names, or
Watch.Design decisions
*grpc.Server, built by humus.NewServicebuilds the server, so it can install the stats handler, the recovery interceptors andWaitForHandlersbefore the end user's options. Taking a*grpc.Serverfrom the end user would leave humus unable to know any of them were set.*Serviceis the registrar. Generated code callsRegisterXServer(registrar, impl), so the end user registers on the Service as they would on a*grpc.Server. Afunc(grpc.ServiceRegistrar)argument would be a second way to say the same thing.AddrandTLS; everything else is agrpc.ServerOption. grpc-go has dozens of server options. Copying them intoSettingswould add a field, a default and a check for each, and put a second name on each one. The two fields are what changes per deployment.net/httprecovers a handler's panic and fails only that request, so a humus service fails a single request the same way over either protocol. It still fails loudly: an ERROR record with the stack, an error span, andInternalto the caller. The cost is that ofnet/http: state the handler left half-changed, such as a held lock, stays that way.os.Exiton three registration mistakes, which bypasses the lifecycle, theOnStopfunctions and telemetry.RegisterServicechecks for the same three first and panics. All three are programming errors, which fail in the first test or the first start.Stopwaits for handlers. Withoutgrpc.WaitForHandlers(true), a handler can outliveShutdown, the App's grace period, and the process's telemetry. The option is experimental in grpc-go. If grpc-go removes it, the Service waits for its handlers itself.Shutdown. A gRPC Service is in the lifecycle's second group, with HTTP: it keeps serving untilShutdown.GracefulStopis what tells clients with long-lived connections to go elsewhere.stats/opentelemetry. Both take injected providers. otelgrpc follows OpenTelemetry's semantic conventions for RPC, which the rest of an OTel setup and the HTTP server's instrumentation use. Its spans have attributes, and none of its API is marked experimental. grpc-go's tracing is inexperimental/opentelemetry, its spans are named likeRecv.grpc.testing.TestService.EmptyCalland carry no attributes, and its metrics follow gRPC's own gRFC A66 instead. An end user who wants A66 metrics passesopentelemetry.ServerOptioninoptsas a second stats handler.grpcmodule addsgoogle.golang.org/grpc, which the core module already has through the OTLP exporters, andgo.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc. The prototype uses otelgrpc v0.72.0 with OTel v1.47.0, the version the core module requires.grpcas a nested module, so a service that doesn't use gRPC doesn't get grpc-go or otelgrpc in its module graph through it.grpc/go.modrequires the core module, with areplaceto../so that CI builds it against the core module in the same commit. A release tags the core module first. The module is taggedgrpc/vX.Y.Z.grpctest.NewClientis one function, but it encodes two traps. Thednsresolver reaches the real network, and crashes a synctest bubble. AndHost.Dialdoesn't have the signaturegrpc.WithContextDialertakes. A test that dials a Service needs both right, every time. It is plaintext by default, and later transport credentials inoptsreplace that, so a TLS test passescredentials.NewTLSwith the CA's pool.grpclogreads its variables from the real environment and writes to the realos.Stderr. Replacing it meansgrpclog.SetLoggerV2, which is global state, and humus sets none. This proposal's stories add grpc-go'sGRPC_*variables and its stderr to the lifecycle page's list of ambient leaks. An end user who wants grpc-go's logs in their telemetry sets the logger inmain.reflection.Register(svc), whichGetServiceInfomakes work.Testing
The tests below come from a prototype in a scratch module that uses this module through a
replace. It passesgo test -race -count=3with 90% coverage. They use grpc-go'sinterop/grpc_testingservice. Its generated code ships with grpc-go, so the repository holds no.protofile and CI needs noprotoc.An RPC in flight when SIGTERM arrives finishes, and its span is recorded:
An RPC that never returns is cut off at the grace period:
The Service passes the contract every Service passes:
Its subtest "Shutdown is safe after Serve has returned on its own" reports SKIP, as it does for an HTTP Service:
Servekeeps serving after its context is done.The tests also show the following. Each passes in the prototype.
codes.Internal, and the next RPC succeeds.humustest.Telemetry'sLogs()holds one ERROR record with the RPC's trace ID, and the RPC's span has statusError.Settings.TLSholding a certificate thathumustest.NewCAissued, a client built withgrpctest.NewClientandcredentials.NewTLS(&tls.Config{RootCAs: ca.Pool()})succeeds. No key, certificate or password is written into the repository.RegisterServiceafterServe, twice for one service, or with an implementation of the wrong type panics, and the test process doesn't exit.InitpassesAddr: "nope"exits 1 withinit: Addr: must be host:port. Through the end user'sconfigchain, the same value exits 78, naming the variable.Initthat fails afterNewServiceleaves no listener open. A second App on the sameHostlistens on the same address.The package's own tests follow #631's list:
CheckAddraccepts and rejects with a table,NewServicenames each bad field, andDefaultSettings()passesNewService. AnExamplein thegrpcpackage runs an App againstgrpc_testing, and the docs site gets a "gRPC services" page.grpc-go has one rule for synctest that the docs page states. A client's target must not use the
dnsresolver, or the test binary crashes, as shown under Background.grpctest.NewClientusespassthrough, so a test that uses it is safe.Migration from today
grpc.NewApi()humusgrpc.NewService(s, settings, opts...), in the end user'sInit(*Api).RegisterService, called by generated code(*Service).RegisterService. GeneratedRegisterXServer(svc, impl)calls don't change.Api: not possible since #485humusgrpc.Appinbedrock.Main, or the Service in ahumus.Appinternal/grpcserver's unboundedGracefulStopShutdown, bounded by the grace period, thenStopstats/opentelemetryfed fromotel.Get*s.Telemetryhealth.Monitoris health-checked implicitlyinternal/grpchealthgrpc/internal/echopbin testsinterop/grpc_testingInternal, and the panic is loggedOpen questions
The package name.
grpccollides withgoogle.golang.org/grpc, which every file that registers a service, passes an option or returns a status also imports. So every end user aliases one of them. The options:github.com/z5labs/humus/grpcas the charter names it, and alias it ashumusgrpcin every example;grpc/, and put the server ingithub.com/z5labs/humus/grpc/grpcserver, next togrpc/grpctest. It matches the HTTP server foundation'shttpserver, and leaves room for a gRPC client package in the same module later.I recommend the second. Only the import path changes; the API above is the same.
Streams that never end.
GracefulStopwaits for every RPC, streams included. A server stream meant to run until the client leaves, such as a subscription, only ends whenStopcancels it at the end of the grace period. Its handler's context isn't done until then. So a service with such a stream open exits 1 with a*GracePeriodErroron every deployment. The options:Shutdownstarts, cancel the context of every streaming RPC, and let unary RPCs finish within the grace period. A client sees the stream end withCanceled, and reconnects to another pod, as it must after any disconnect. A finite stream, such as a large download, is cut short too.I lean to (b). The grace period exists for work that finishes, and a stream that never finishes can only end with an error either way. (b) ends it early, without failing the App.
The default address.
:9090is common for gRPC on Kubernetes, and doesn't clash with an HTTP server on 8080. grpc.io's examples use:50051. Is:9090right?The App's durations.
Apphas noDrainDelayorShutdownGracePeriodfields. An operator changes them with the lifecycle's variables, and code that needs other defaults useshumus.AppwithNewService. ShouldApphave the two fields instead? Then a zero value would have to mean the package's default, which the settings convention rules out forSettings.Out of scope
grpctest.NewClientis for tests only.opts. A humus API for it comes later, after REST security.cmuxor(*grpc.Server).ServeHTTP.ServeHTTPis experimental and lacks some of grpc-go's transport features. A process that serves both uses two Services on two ports, in onehumus.App.References
grpc/api.go,internal/grpcserver/server.go,internal/grpchealth/server.go(*Server).GracefulStop,WaitForHandlers,grpclog,stats/opentelemetry,interop/grpc_testingAll reactions