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 five open questions, applies humus.Main, and takes in the charter's revision 7. The open questions:
What humus.Main changes:
From the charter's revision 7, exposing health probes, producing to Kafka and RabbitMQ are wave 4. Failure injection, in wave 5, strips scenarios from the records this Service receives. The prototype's whole-App tests are rewritten on Summary
func main() {
humus.Main(app.Init, humus.WithTelemetry(buildTelemetry))
}
Proposed API
// NewService checks settings and returns a Service. It opens no connection:
// Serve builds the client and joins the group.
//
// The Service reports through s.Telemetry: its spans, the three messaging
// instruments and its own logs under the scope
// github.com/z5labs/humus/queue/kafka, franz-go's logs under
// github.com/twmb/franz-go/pkg/kgo, and franz-go's broker metrics, from
// kotel's Meter, under github.com/twmb/franz-go/plugin/kotel.
func NewService(s *humus.Setup, settings Settings, p queue.Processor[Message]) (*Service, error)So the package is What
|
AtLeastOnce |
AtMostOnce |
|
|---|---|---|
| the brokers are unreachable | franz-go keeps retrying and logs each failure, and kotel counts each failed attempt; the Service keeps serving | the same |
Telemetry
Unchanged, except that this bullet is new, after Metrics:
- franz-go's broker metrics, from kotel's
Meter.NewServicebuildskotel.NewMeter(kotel.MeterProvider(s.Telemetry.MeterProvider)), andServehands it to the client.- By broker (
node_id), it counts the connections opened, the connection errors and the connections closed, the bytes read and written, and the read and write errors. By broker and topic (topic), it counts the records and the uncompressed bytes fetched. Its produce counters stay empty, because the Service produces nothing. - The instruments are kotel's, under its own scope,
github.com/twmb/franz-go/plugin/kotel, with names such asmessaging.kafka.connects.count,messaging.kafka.connect_errors.countandmessaging.kafka.fetch_records.count. They aren't conventions, because OpenTelemetry's messaging conventions define none for a client's connections or bytes. - kotel's options are left at its defaults. Connection errors are their own counter, not an
outcomeattribute onmessaging.kafka.connects.count. - kotel uses the global
MeterProviderwhen it is given none. The lifecycle rejects an SDK with a nilMeterProvider, so that never happens. - On kfake, a clean shutdown counts one read error for the broker, during shutdown and never before it. An alert on
messaging.kafka.read_errors.countshould allow for that. - As with semconv, humus pins the kotel version it uses. A kotel release that renames its instruments is a breaking change to
queue/kafka.
- By broker (
Running on humus.Main
This section replaces The App. humus.Main runs the Services of any runtime, and its defaults are the ones this proposal chose for a consumer. So the package has no App, no Main and no defaults of its own:
humus.Main |
Why it fits a consumer | |
|---|---|---|
ShutdownGracePeriod |
20s |
With telemetry's default ShutdownTimeout of 5s, the whole shutdown takes at most 25s. That is inside Kubernetes' default terminationGracePeriodSeconds of 30s, with a margin for the process to exit. A Processor that can't finish a record within it is cut short, and the record is processed again. |
Drain |
none | Nothing load-balances a consumer. Its traffic stops when it leaves the group, which it does as soon as it has drained. |
Telemetry |
telemetry.New with DefaultSettings() |
A consumer in the middle of a traced pipeline chooses its exporter with humus.WithTelemetry. |
| health | none | Proposal: exposing health probes, in wave 4, decides how a consumer's pod serves probes. |
Telemetryis no longer required.Apprequired it, so that a consumer couldn't start asunknown_serviceby accident. humus.Main decided otherwise for every runtime. The defaultBuilderwrites to stdout, and a service that names itself or exports anything passeshumus.WithTelemetry. Tests passhumustest.NewTelemetry()'sBuilder, or none at all.- The end user's
Initbuilds the Service.App'sInitreturned theSettingsand theProcessor, andAppcalledNewService. Now that call is in the end user'sInit, which returns the Service along with any others the process runs. Two topics with differentDeliveryare two Services with two group IDs, returned together. More than one Service is no longer the advanced path. - Which
Main. A consumer on its own, or next to a probes or metrics server, useshumus.Main. Next to a server that a load balancer routes to, such as an API, it uses that server's package'sMain, as humus.Main's revision 2 decides. The consumer still stops taking records as soon as shutdown starts, and its records in flight have the drain and the grace period to finish. - An operator changes the defaults with
HUMUS_SHUTDOWN_GRACE_PERIODandHUMUS_SHUTDOWN_DRAIN_DELAY, as for any App. A team that wants another default in code passeshumus.WithShutdownGracePeriodtohumus.Main. - No health. The process runs exactly the Services its
Initreturns. Proposal: exposing health probes, in wave 4, decides how a pod with no HTTP server of its own serves probes, and whether a consumer exposes them by default.
In use
func main() {
humus.Main(Init, humus.WithTelemetry(buildTelemetry))
}
func Init(ctx context.Context, s *humus.Setup) ([]humus.Service, error) {
d := kafka.DefaultSettings()
env := func(name string) config.Value[string] { return config.Env(s.Process.Environment, name) }
list := func(v string) []string { return strings.Split(v, ",") }
settings, err := config.Struct(func(r *config.Resolver) kafka.Settings {
k := d
k.Brokers = r.Get(env("KAFKA_BROKERS").Map(list).Validate(kafka.CheckBrokers))
k.GroupID = r.Get(env("KAFKA_GROUP_ID").Validate(kafka.CheckGroupID))
k.Topics = r.Get(env("KAFKA_TOPICS").Map(list).Validate(kafka.CheckTopics))
k.SessionTimeout = r.Get(env("KAFKA_SESSION_TIMEOUT").Parse(time.ParseDuration).Default(d.SessionTimeout).Validate(kafka.CheckSessionTimeout))
return k
}).Read(ctx)
if err != nil {
return nil, err // exit 78, naming every bad variable
}
pool, err := newPool(ctx, s) // dials through s.Host.Dial; closed in s.OnStop
if err != nil {
return nil, err
}
h := orders.NewHandler(pool) // idempotent: an order already stored is skipped
svc, err := kafka.NewService(s, settings, queue.ProcessorFunc[kafka.Message](h.Handle))
if err != nil {
return nil, err
}
return []humus.Service{svc}, nil
}What waves 4 and 5 build on this
This section is new. The charter's revision 7 has four later proposals build on this one. Nothing in this API is designed for them, and each one's stories make whatever change it needs here.
-
Proposal: exposing health probes (wave 4) decides how a consumer's pod, which has no HTTP server of its own, serves probes, and whether a consumer exposes them by default. It does that in terms of
humus.Main, Options that wrapInit, and the Services the end user'sInitreturns, as for every wave-3 runtime. Whether a consumer whose brokers are unreachable is ready (open question 5) is its decision too. -
Proposal: producing to Kafka (wave 4) is in this module. It builds on three things here:
- the connection settings,
Brokers,TLSandSASL, with their checks; - the unexported dialer that runs the TLS handshake over the
Host's connection; - the header carrier, which the Service extracts the trace context through and a producer injects through.
kotel's
Metercounts a producer's records and bytes too. How a producer's settings share these fields, and whether they move into a type of their own, is that proposal's decision. - the connection settings,
-
Proposal: RabbitMQ (wave 4) implements the
queuecontract, as How RabbitMQ (story(queue): implement a runtime for rabbitmq #310) would fit sketches. It shares no code withqueue/kafka. -
Proposal: failure injection (wave 5) strips scenarios from the records this Service receives, before the end user's
Processorsees them. Its seam is the Service's processing of a record: the Service extracts the propagator's context, baggage included, from the record's headers, starts the record's span, and then callsProcess. Stripping belongs after the extraction and beforeProcess, inside the Service, so everyProcessoris covered without the end user wrapping anything. The wave-5 proposal designs that step and whatever configures it.
Design decisions
Unchanged, except that these bullets replace "No kotel." and "Dependencies.":
- kotel's
Meter, not its tracer. franz-go's OpenTelemetry plugin has two halves.- Its
Meteris a set of client hooks for what humus can't see from outside the client: connections, bytes, and read and write errors, per broker. It takes aMeterProvider, starts no goroutine, and works insidesyncteston humustest'sHost. Its names aren't conventions, but no conventions exist for these. - Its tracer uses semconv v1.18.0, whose names have since changed, and names its process span
<topic> process. It starts areceivespan for every buffered record, doubling the spans. The process span, the propagation through headers and the three convention instruments are about a hundred lines that humus controls, so they stay humus's own.
- Its
- Dependencies.
github.com/twmb/franz-gois the client, as before: pure Go, maintained, with the group, commit and dialler hooks this design needs, and itspkg/saslmechanisms live in the same module.github.com/twmb/franz-go/plugin/kotelis a module of its own. It requires franz-go and OpenTelemetry, which the module already has, and v1.7.1 builds with franz-go v1.22.2 and OpenTelemetry v1.47.0.github.com/twmb/franz-go/pkg/kfakeis a test dependency. All three are in the nested module.
These bullets are new:
- No App and no
Main.humus.Main's defaults are a consumer's. An App would only repeat them, and aMainwould apply nothing, as forjob. - No test package.
httpservertestexists because an HTTP service's defaults have to reach its tests, and HTTP/2 over a test Host is easy to get wrong. A consumer has neither problem.humustest.Startalready runs it onhumus.Main's defaults. kfake listens on the test Host withkfake.ListenFn(h.Listen), and a test's producer dials it withkgo.Dialer(h.Dial). The tests' helpers are a few lines each, which no API would save. - An at-most-once processing error doesn't stop the Service. The record was committed before
Processran, so a restart doesn't redeliver it. Stopping would restart the pod for nothing. The error is recorded on the record's span and aserror.typeonmessaging.process.duration, and logged at ERROR. Settingsgrows on request. The fields are the ones before the reset, plusSASLandMaxPollRecords. Other franz-go options become fields when end users ask for them, each with a default and a check.- Unreachable brokers don't stop the Service. franz-go retries each connection with backoff and logs each failure, and kotel counts each failed attempt. Records are processed once the brokers are back. Failing
Serveafter a bound would turn a broker outage into a crash loop of every consumer, and readiness is the usual answer to a consumer that can't connect. If end users find that a consumer should fail instead, a bound can be added as a setting, which breaks no one.
Testing
The section kfake on humustest's Host and the section Integration tests are unchanged. Whole-App tests is replaced.
Whole-App tests
These tests pass under go test -race -count=3. They ran against the prototype's queue.go, settings.go and service.go, with kotel's Meter added to the client's options and app.go removed. The dependencies were humus at a155d299, bedrock v0.24.0, franz-go v1.22.2, kotel v1.7.1, kfake v0.0.0-20261013022759-7b6cac30a57a and OpenTelemetry v1.47.0. Each App runs with humustest.Start, so on humus.Main's defaults, on the Host kfake listens on:
// consumes returns an Init whose only Service consumes settings with p.
func consumes(settings kafka.Settings, p queue.ProcessorFunc[kafka.Message]) func(context.Context, *humus.Setup) ([]humus.Service, error) {
return func(_ context.Context, s *humus.Setup) ([]humus.Service, error) {
svc, err := kafka.NewService(s, settings, p)
if err != nil {
return nil, err
}
return []humus.Service{svc}, nil
}
}
func TestService_at_least_once_stops_on_a_failure_and_redelivers(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
h := humustest.NewHost(t)
c := cluster(t, h, 1) // kfake.NewCluster(kfake.ListenFn(h.Listen), kfake.SeedTopics(1, "orders"))
defer c.Close()
produce(t, h, c, 5) // offsets 0 to 4
bad := errors.New("cannot process")
run := func(fail bool) (bedrock.ExitStatus, []int64) {
var seen []int64
p, status := humustest.Start(t, consumes(settings(c.ListenAddrs()...), func(ctx context.Context, m kafka.Message) error {
seen = append(seen, m.Offset)
if fail && m.Offset == 3 {
return bad
}
return nil
}), humustest.WithApp(humus.WithHost(h.Host())))
if !fail {
synctest.Wait() // every record is processed and committed
p.Signal(syscall.SIGTERM)
}
return <-status, seen
}
st, seen := run(true)
require.Equal(t, humus.ExitFailure, st.Code())
require.ErrorIs(t, st.Cause(), bad)
require.Equal(t, []int64{0, 1, 2, 3}, seen)
require.Equal(t, int64(3), committed(c), "0 to 2 are committed, 3 is not")
st, seen = run(false)
require.Zero(t, st.Code(), st.Cause())
require.Equal(t, []int64{3, 4}, seen, "3 is delivered again")
require.Equal(t, int64(5), committed(c))
})
}
func TestService_SIGTERM_lets_the_record_in_flight_finish_and_commits_it(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
h := humustest.NewHost(t)
c := cluster(t, h, 1)
defer c.Close()
produce(t, h, c, 5)
entered := make(chan struct{}, 5)
p, status := humustest.Start(t, consumes(settings(c.ListenAddrs()...), func(ctx context.Context, m kafka.Message) error {
entered <- struct{}{}
time.Sleep(3 * time.Second)
return nil
}), humustest.WithApp(humus.WithHost(h.Host())))
<-entered // offset 0 is in flight
p.Signal(syscall.SIGTERM)
st := <-status
require.Zero(t, st.Code(), st.Cause())
require.Equal(t, int64(1), committed(c), "0 finished and is committed; 1 to 4 never started")
})
}
func TestService_records_franz_go_broker_metrics_with_kotel(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
h := humustest.NewHost(t)
c := cluster(t, h, 2)
defer c.Close()
produce(t, h, c, 10)
tel := humustest.NewTelemetry()
p, status := humustest.Start(t, consumes(settings(c.ListenAddrs()...), func(context.Context, kafka.Message) error { return nil }),
humustest.WithApp(humus.WithHost(h.Host()), humus.WithTelemetry(tel.Builder())))
synctest.Wait()
p.Signal(syscall.SIGTERM)
require.Zero(t, (<-status).Code())
require.Len(t, tel.Spans(), 10)
require.Equal(t, int64(10), sum(t, tel, "github.com/twmb/franz-go/plugin/kotel", "messaging.kafka.fetch_records.count"))
})
}
func TestService_keeps_serving_while_the_brokers_are_unreachable(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
h := humustest.NewHost(t) // nothing listens on it
tel := humustest.NewTelemetry()
p, status := humustest.Start(t, consumes(settings("127.0.0.1:9092"), func(context.Context, kafka.Message) error { return nil }),
humustest.WithApp(humus.WithHost(h.Host()), humus.WithTelemetry(tel.Builder())))
time.Sleep(time.Minute)
select {
case st := <-status:
t.Fatalf("the App exited while the brokers were unreachable: %v", st.Cause())
default:
}
p.Signal(syscall.SIGTERM)
require.Zero(t, (<-status).Code())
require.Positive(t, sum(t, tel, "github.com/twmb/franz-go/plugin/kotel", "messaging.kafka.connect_errors.count"))
})
}The helpers are a few lines each:
producewrites records with a franz-go client that dialsh.Dial.settingsisDefaultSettings()with the brokers given, a group ID and the topic.committedsums the group's committed offsets fromc.GroupInfo.sumadds up a counter's data points fromtel.Metrics.
A separate check showed that kotel's instruments reach the Setup's MeterProvider, and none reaches OpenTelemetry's global one.
The post's list of what the prototype's other tests show stands. Those tests ran with kafka.App, and the stories run them with humustest.Start. The stories add tests for what the prototype leaves out. The post's list stands, except that its last bullet becomes:
- A runnable
Example, or apackage mainwith its test, of a consumer onhumus.Main, as the charter requires.
The list also gains these bullets:
- kotel's instruments are recorded under its scope with
node_idandtopic, and none reaches OpenTelemetry's globalMeterProvider, even when one is set. - With the brokers unreachable, franz-go's connection failures reach
humustest.Telemetry'sLogs()through the log adapter.
Migration from today
Unchanged, except for these rows:
| Before the reset | After |
|---|---|
queue.NewApp(runtime) |
humus.Main(Init), with the end user's Init returning the Service from kafka.NewService |
otel.Get*, humus.Logger, kotel, kslog |
s.Telemetry; kotel's Meter alone, given s.Telemetry.MeterProvider, for franz-go's broker metrics |
Wording fixes to the post
- The note at the top now reads: wave 3, building on Proposal: humus.Main as well as the four wave-1 proposals it names, and depending on no other wave-3 proposal. Proposal: exposing health probes (wave 4) decides how a consumer's pod serves probes. Proposal: producing to Kafka and Proposal: RabbitMQ (wave 4) build on this, and Proposal: failure injection (wave 5) strips scenarios at this Service.
- In Background, "What wave 1 settled":
- "Each runtime package provides Services for composition and an App built on
humus.Appwith its own defaults" is replaced by humus.Main: each runtime package provides Services, and aMainonly where it needs other defaults thanhumus.Main's. - "No wave-2 App serves health probes" reads "No wave-3 runtime serves health probes", citing the charter's revision 7.
- The list gains humus.Main:
humus.Main, the Drain, andhumustest.Start.
- "Each runtime package provides Services for composition and an App built on
- In Design decisions, "One
Processorand oneDeliveryper Service" ends "and two semantics are two Services with two group IDs, returned from oneInit." - Out of scope:
- "Producing, and an outbound Kafka client: the charter's "Later" list" reads "Producing: Proposal: producing to Kafka (wave 4)."
- "RabbitMQ (story(queue): implement a runtime for rabbitmq #310): "Later"" reads "RabbitMQ (story(queue): implement a runtime for rabbitmq #310): Proposal: RabbitMQ (wave 4)."
- The bullet on health probes ends "in wave 4".
- Prototype: the post's code stands as what the first tests ran against. Its
kafka/app.gois the App this revision removes. - References gain humus.Main's revision 2 and the charter's revision 7. The kotel entry reads "franz-go
kotel:NewMeterandMeterProvider; its tracer is not used".
Open questions
None.
Closing: queue and the Kafka runtime have landedThe revision's stories are done:
The revision left no open questions. One finding came out of #731: against Kafka itself, a fenced-out member's partition comes back revoked, not lost, so its late commit can move the new owner's committed offset. At-least-once still holds, and the fix changes none of What the revision hands on belongs to the wave-4 and wave-5 proposals, as its "What waves 4 and 5 build on this" lists. Exposing health probes decides how a consumer's pod serves probes. Producing to Kafka builds on the connection settings, the TLS dialer and the header carrier. RabbitMQ implements the |
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) adds probes to the App proposed here. It answers the open question Proposal: humustest handed it: unit tests run on kfake, listening on humustest's
Host.Summary
queuekeepsProcessorand gainsDelivery. It is the contract between the end user's code and any broker: what a message's processing returns, and when the message is acknowledged.Consumer,Acknowledger,Runtime,AppandErrEndOfQueueare removed.queue/kafkais a nested module with aServiceand anApp. The Service joins a consumer group with franz-go, dials the brokers through theHost, and processes each partition's records one at a time, in offset order. The App runs one Service onhumus.App.Serve's context the Service stops fetching, lets the record in flight on each partition finish, commits, and leaves the group, all within the grace period.Host, insidesynctest. kfake takes a listen function and was built forsynctest; a prototype's whole-App tests pass under-race.Background
The packages before the reset
queuehad three phases and a runtime (queue/queue.go):Consumer[T],Processor[T]andAcknowledger[T], each with aFuncadapter (L18-L57);ErrEndOfQueue, for a finite queue (L13-L16);RuntimeandApp, on bedrock v0.21.0'sRun(context.Context) errorshape (L59-L85).The end user only ever wrote a
Processor[kafka.Message].ConsumerandAcknowledgerwere the seams ofqueue/kafka's own orchestrators (Consumer[fetch],Acknowledger[[]*kgo.Record], #365), exported fromqueuebecause they had to live somewhere (event_loop.go#L28-L30).queue/kafkabuilt a franz-go client inProcessQueue, ran a goroutine per assigned partition, and offeredAtLeastOnce(topic, p)andAtMostOnce(topic, p)per topic (kafka.go,at_least_once.go,at_most_once.go).What was wrong with it
recordProcessor.processlogged a processing error and returned nothing (runtime.go#L92-L104), andprocessFetchessent every record on to be committed whatever happened (at_least_once.go#L94-L107). A failed record was never redelivered, which is at-most-once.// TODO: how should this be handled?(at_least_once.go#L118-L131,at_most_once.go#L112-L122). At-least-once also committed one record at a time, a round trip per record.at_most_once.go#L148-L161).event_loop.go#L265-L288). The new owner reprocessed it, and a late commit could rewind the new owner's.ProcessQueue's context cancelled the record in flight and every loop at once, and nothing committed the final offsets (kafka.go#L213-L223). The package doc said it did (doc.go#L164-L171).otel.Get*, and logs fromhumus.Logger(kafka.go#L178-L191,otel.go#L17-L27). The brokers were dialled with franz-go's own dialer.AtMostOnceRuntime,NewAtLeastOnceRuntime, decoder functions,WithKafkaOptionsand amessaging.client.processing.failuresmetric, none of which existed (doc.go#L15,#L122,#L201,#L225). The two metrics that did exist,messaging.client.messages.processedandmessaging.client.messages.committed(story(queue/kafka): add otel metrics for message processing and commits #359), aren't in OpenTelemetry's conventions (otel.go#L37-L52).testcontainersbuild tag, which took about two minutes (story(queue/kafka): investigate testcontainers peformance #368). story(kafka): add unit tests using kfake #431 asked for kfake instead.What wave 1 settled
humus.Servicegets two signals:Serve's context is done as shutdown starts, before the drain delay, andShutdownis called after it, with the grace period as its deadline. A queue consumer stops on the first (#630).humus.Appwith its own defaults.Settings, read no environment variables, and export a check per field (#631). Proposal: configuration for humus apps #631's migration note that Kafka's brokers, group ID and TLS would getHUMUS_KAFKA_*variables predates that revision and is superseded: this package reads no variables.s.Telemetry, never the globals (#632).syncteston bedrocktest's process and humustest'sHost(#633).Proposed API
queuequeueis in the core module and depends on nothing but the standard library.The package doc is the contract every broker package implements:
AtLeastOnceends the Service. The message is not acknowledged, the Service stops as it would on a signal, and itsServereturns the error, so the App exits 1. The message is delivered again when the App restarts, to it or to another replica.AtMostOnceis reported, and processing goes on. The message was already acknowledged, so stopping wouldn't bring it back.Processorcrashes the process.Serve's context a Service stops taking messages, lets the ones in flight finish, acknowledges them, and returns.Process's context is not cancelled when shutdown starts, only when the grace period runs out.Processor's. AProcessorthat wants to retry a transient failure does so itself, for example with bedrock'sretrymodule, before it returns an error.queue/kafkaqueue/kafkais a nested module,github.com/z5labs/humus/queue/kafka, so franz-go stays out of the module graph of users who don't consume Kafka.Settings
Brokershost:port, with a host and a port from 1 to 65535CheckBrokersGroupID""CheckGroupIDTopicsCheckTopicsDeliveryqueue.AtLeastOnceat-least-once,at-most-oncequeue.CheckDeliveryTLSnil, which means plaintextSASLnil, which means no authenticationSessionTimeout45sCheckSessionTimeoutRebalanceTimeout60sCheckRebalanceTimeoutFetchMaxBytes50 MiBCheckFetchMaxBytesMaxPollRecords500CheckMaxPollRecordsThe answer to #631's open question 2, optional settings, for these fields:
Brokers,GroupIDandTopicsare required. They have no default, and their zero values fail their checks. An end user who sources them withconfiggets exit 78 naming the variable when one is missing.TLSandSASLare optional.nilturns them off, astelemetry.OTLPSettings.TLSdoes.TLScovers mTLS (story(kafka): add mTLS authentication support #352): the end user puts the client certificate inCertificates.SASLis franz-go's ownsasl.Mechanism, from franz-go's own module, so the end user picksplain,scram,oauthorawsand supplies the credentials from wherever they keep them. humus decides nothing about secrets.Deliveryis a setting rather than a constructor argument, soDefaultSettingsgives the safe one and the check covers it. It is the end user's design decision, not an operator's, so mostInits set it in code rather than sourcing it.SessionTimeoutandFetchMaxByteskeep franz-go's defaults.RebalanceTimeoutgoes from the pre-reset 30s to franz-go's 60s, because a revoked partition's record in flight now has to finish within it.MaxPollRecordsis new: it bounds how many records a poll hands out, which bounds how much an at-most-once batch can lose and how long a revoke waits.What
Servedoeskgo.ConsumerGroup,kgo.ConsumeTopics,kgo.DisableAutoCommit,kgo.BlockRebalanceOnPolland the partition callbacks below. It dials throughs.Host.Dialwithkgo.Dialer. franz-go refuseskgo.Dialertogether withkgo.DialTLSConfig("cannot set both Dialer and DialTLSConfig"), so withTLSset, an unexported dialer runs the TLS handshake over theHost's connection, settingServerNamefrom the broker's host when the config leaves it empty, asDialTLSConfigdoes.PollRecords(ctx, MaxPollRecords), hands each partition's records to that partition's goroutine, and allows rebalancing again.AtLeastOnce, after each batch, it commits the last record that was processed, and every record before it, with oneCommitRecords.AtMostOnce, it commits the batch before it processes any of it, and then processes every record of the batch.RebalanceTimeout.Serve's context is done orShutdownis called, it stops polling, stops every partition after its record in flight, commits, leaves the group withLeaveGroupContext, closes the client, and returnsnil. Leaving the group rebalances at once, so the other replicas take the partitions without waiting forSessionTimeout.Processis given a context that keeps the values ofServe's context, carries the record's span, and is cancelled only whenShutdown's context is done. So the record in flight isn't cut short when shutdown starts, and a slow one is cut short when the grace period runs out. ThenShutdownstops waiting: it cancels that context and the leave, and returns an error wrapping its context's error, which the lifecycle turns into a*humus.GracePeriodError. Records whose processing was cut short are not committed.Failures
AtLeastOnceAtMostOnceProcessreturns an errorServereturns the error; exit 1ProcesspanicsServereturns the error; exit 1Servereturns the error; exit 1An at-least-once failure stops the whole Service, not just the partition. The other partitions finish their record in flight and commit, so only the failed record and those after it are processed again.
Telemetry
Everything comes from
s.Telemetry. The instrumentation scope isgithub.com/z5labs/humus/queue/kafka.A span per record. It is named
process <topic>, of kindCONSUMER, and its parent is the contexts.Telemetry.Propagatorextracts from the record's headers, so a producer's trace continues through the consumer. Its attributes aremessaging.system=kafka,messaging.operation.type=process,messaging.operation.name=process,messaging.destination.name,messaging.destination.partition.id,messaging.consumer.group.nameandmessaging.kafka.offset. A processing error is recorded on it, witherror.typeand an error status. The record's key is not recorded, because it often holds a customer's identifier.Metrics, from semconv v1.43.0's
messagingconv:messaging.client.consumed.messages, a counter of the records handed to theProcessor;messaging.process.duration, a histogram ofProcess, witherror.typewhen it fails;messaging.client.operation.duration, a histogram of each commit, withmessaging.operation.name=commitanderror.typewhen it fails.Each has
messaging.system,messaging.consumer.group.name,messaging.destination.nameandmessaging.destination.partition.id. These replace story(queue/kafka): add otel metrics for message processing and commits #359's two counters, which weren't conventions. The messaging conventions are still in development, so humus pins the semconv version it uses, and changing it is a breaking change.Logs. The Service logs, at INFO, the partitions it is assigned, has revoked and loses, and at ERROR, a processing error under
AtMostOnce. An error that ends the Service is returned, and the lifecycle logs it. franz-go's own logs go throughkgo.WithLoggerand an unexported adapter onto the OpenTelemetry Logs API, with the scopegithub.com/twmb/franz-go/pkg/kgo. ItsLevelis the lowest of franz-go's levels theLoggerProviderisEnabledfor, so franz-go builds no record that would be dropped.The App
ShutdownTimeoutof 5s, the whole shutdown takes at most 25s, inside Kubernetes' defaultterminationGracePeriodSecondsof 30s with a margin for the process to exit, as for a job. AProcessorthat can't finish a record within it is cut short, and the record is processed again.HUMUS_SHUTDOWN_GRACE_PERIODandHUMUS_SHUTDOWN_DRAIN_DELAY, whichhumus.App.Runreads.Apphas no fields for them: a zero field would mean zero, and an end user who wants other defaults in code useshumus.App.Telemetryis required, as onhumus.App. A defaultBuilderwould name the serviceunknown_service, and a consumer in the middle of a traced pipeline needs to choose its exporter. Tests passtelemetry.NoopBuilderorhumustest.Telemetry'sBuilder.Initdescribes. Proposal: exposing health probes, in wave 3, decides how a pod with no HTTP server of its own serves probes, and whether this App does by default. It adds that with a new field onApp, whoseRunappends one more Service;Init's shape doesn't change.humus.App. Two topics with differentDelivery, a consumer next to an HTTP API, or a health Service today: the end user returnskafka.NewService's Service from their ownhumus.AppInit, next to the others. That is the advanced path the lifecycle documents.In use
Design decisions
queueis the contract, not a framework. It holds what the end user's code depends on,Processor, and what it must know,Deliveryand what each does with an error. A broker package implements both against its own client.ConsumerandAcknowledgerare removed. They werequeue/kafka's internal seams, and no end user implemented them. A shared acknowledger would also hide the difference that matters most between brokers: Kafka acknowledges a partition's offset, so a commit covers every earlier record, while RabbitMQ acknowledges one delivery.queue/rabbitmqnested module provides a Service that takesSettingswith aDeliveryand aqueue.Processor[rabbitmq.Message]. UnderAtLeastOnceit acks a delivery afterProcessreturns nil, and on an error stops, so the broker requeues the unacked delivery when the channel closes. UnderAtMostOnceit consumes with auto-ack. It followsqueue's contract and shares no code withqueue/kafka.ErrEndOfQueueis removed. A topic doesn't end. A finite queue is a job, and the lifecycle already ends an App whose Service returns nil.Processorand oneDeliveryper Service. The pre-reset per-topic choice let one consumer group mix semantics, and its revoke and commit paths differed per topic. AMessagecarries its topic, so oneProcessorcan dispatch on it, and two semantics are two Services with two group IDs.Processor. A transient failure is theProcessor's to retry, and bedrock'sretrymodule (v0.24.0) does that with a budget and backoff. Neitherqueuenorqueue/kafkaimports it, because a retry policy is the end user's decision and a retry inside the Service would block the partition just the same.BlockRebalanceOnPoll, with rebalancing allowed right after each poll. Records are handed out only to partitions this member owns, and a revoke can't run while records are being dispatched. Processing happens after rebalancing is allowed again, so a slow record never holds up a rebalance beyond the revoke callback, which waits only for the record in flight.Serve's context. A consumer has no load balancer in front of it, so there is nothing to wait for during the drain delay: as the lifecycle decided, it stops taking work at once.Shutdownwaits for that drain, bounded by the grace period.Process's context isn't cancelled when shutdown starts. If it were, aProcessorhonouring it would fail the record in flight, and under at-least-once every SIGTERM would end in exit 1. Its context is cancelled when the grace period runs out.Serve, notNewService. franz-go's client starts a metadata goroutine as soon as it is built, and joining the group beforeServewould claim partitions before anything processes them. SoNewServiceacquires nothing and registers noOnStopfunction, and anInitthat fails after it leaks nothing.Host. That puts the Service on humustest's network in tests, astelemetrydoes with the OTLP exporters.<topic> process. It starts areceivespan for every buffered record, doubling the spans, and it falls back to the global providers for any option it isn't given. The process span, the propagation through headers, and three instruments are about a hundred lines that humus controls. The cost is franz-go's broker-level metrics, such as bytes read and connection errors; open question 4 asks whether to add kotel'sMeterfor those alone.conc,kslogorkadm. The pre-reset package usedsourcegraph/concfor goroutine pools, whichsynccovers, andkslogto bridge to slog, which humus no longer has.kadmstays only in the integration tests.github.com/twmb/franz-gois the client, as before: pure Go, maintained, with the group, commit and dialler hooks this design needs, and itspkg/saslmechanisms live in the same module.github.com/twmb/franz-go/pkg/kfakeis a test dependency. Both are in the nested module.Settingstakes decoded values.TLSis a*tls.ConfigandSASLa mechanism, so how certificates and credentials are stored is the end user's, as Proposal: configuration for humus apps #631 decided.Testing
kfake on humustest's
Host#633 left this proposal one question: can kfake listen on humustest's
Host, and if not, what do the unit tests run against? It can.kfake.ListenFn(fn func(network, address string) (net.Listener, error))overridesnet.Listen. Passingh.Listenputs the fake cluster on humustest's network.synctest. It also exportskfake.VirtualNetwork, whose doc says it replaces TCP withnet.Pipeconnections because "a goroutine blocked on real TCP I/O is not durably blocked". humustest's network is the same idea, so humustest'sHostis used, and the App reaches the cluster throughs.Host.Dialwith no test-only seam.v0.0.0-20261013022759-7b6cac30a57a: a cluster built withkfake.ListenFn(h.Listen)insidesynctest.Testlistens on127.0.0.1:<port>of humustest's network, and a client dialling withkgo.Dialer(h.Dial)produces, joins a group, consumes, commits and leaves, under-race, in no fake time.cluster.GroupInfo(group)gives the committed offsets and members to assert on.kfake.TLS(cfg)serves TLS, so mTLS is tested with certificates fromhumustest.NewCA, never fixed ones.A test builds the cluster inside the bubble, with the bubble's
t, and closes it before the bubble ends, as the rules for humustest'sHostrequire.Whole-App tests
These tests pass against the prototype below, under
go test -race -count=3:The helpers are a few lines each:
producewrites records with a franz-go client that dialsh.Dial,settingsisDefaultSettings()with the cluster'sListenAddrs(), a group ID and the topic, andcommittedsums the group's committed offsets fromc.GroupInfo.The prototype's other tests show the following:
humustest.Telemetryrecorded tenprocess ordersspans.Settings.TLSset and a kfake cluster that requires a client certificate, both issued byhumustest.NewCA, the App processes and commits as over plaintext.kafka.Servicepasseshumustest.TestService, including "Shutdown is safe after Serve has returned on its own" and the leak check.newServicebuilds a kfake cluster ons.Host.Listenand closes it withs.OnStop.NewServicereports every bad field in one error, naming each.The stories add tests for what the prototype leaves out:
DefaultSettingswithBrokers,GroupIDandTopicsset passesNewService(Proposal: configuration for humus apps #631's list).AtMostOnce, a processing error is logged and the next record is processed, and the batch is committed before its first record is processed.Host).*humus.GracePeriodError, and the record is not committed.processspan, and the three instruments are recorded with their attributes.humustest.Telemetry'sLogs(), and itsLevelfollows theLoggerProvider.Exampleofkafka.App, tested, as the charter requires.Integration tests
The tests against Kafka in a container stay, behind the
testcontainersbuild tag (kafka_testcontainer.go), for what a fake can't show: a real broker's cooperative-sticky rebalances and its fencing. They are the only tests in the package that open a real socket, run in a CI job of their own, and don't count toward the coverage gate. Their main use is to catch where kfake and Kafka differ.Migration from today
queue.Processor[T],queue.ProcessorFunc[T]queue.Consumer,queue.Acknowledger, theirFuncadapters,queue.Runtime,queue.RuntimeFuncqueue.ErrEndOfQueuequeue.NewApp(runtime)kafka.App, orhumus.Appwithkafka.NewServicekafka.NewRuntime(brokers, groupID, opts...)kafka.Settings{Brokers, GroupID, Topics}andkafka.NewService(s, settings, p)kafka.AtLeastOnce(topic, p),kafka.AtMostOnce(topic, p)Settings.TopicsandSettings.Delivery; oneProcessorper Servicekafka.SessionTimeout,RebalanceTimeout,FetchMaxBytesSettingsfields with checks;RebalanceTimeoutdefaults to 60skafka.MaxConcurrentFetcheskafka.WithTLS(cfg)Settings.TLS, dialled throughHost.Dialkafka.Message.Attrskafka.GroupIDAttr,TopicAttr,PartitionAttr,OffsetAttrmessaging.client.messages.processed,messaging.client.messages.committedmessaging.client.consumed.messages,messaging.process.duration,messaging.client.operation.durationotel.Get*,humus.Logger, kotel, kslogs.TelemetryHUMUS_KAFKA_*variables, planned in #631's first postHost(#431); containers only behind the build tagTwo behaviours change for an existing processor:
Open questions
queuepackage, or onlyqueue/kafka?queueholds two small types until a second broker arrives. Folding them intoqueue/kafkais simpler today, but RabbitMQ would then define its ownProcessorand repeat the contract. Recommendation: keepqueue, as the charter's inventory has it.Settingsfields? The proposal keeps the pre-reset set plusSASLandMaxPollRecords. Candidates areConsumeResetOffset(where a new group starts; franz-go and this proposal start at the earliest offset, Kafka's Java client at the latest),InstanceIDfor static membership,FetchMaxWaitandClientID. A field can be added later without breaking anyone.Meteralone, givens.Telemetry.MeterProvider, for its connection, byte and request counters, or leave them out? They aren't conventions, and they add kotel as a dependency.Serveif the group isn't joined within a bound. Readiness is the usual answer, and it belongs to Proposal: exposing health probes in wave 3.Out of scope
queuefits it.Processor's.ProcessordecodesMessage.Valueitself.Prototype
The prototype runs against humus at
c197f923, bedrock v0.23.1, franz-go v1.22.2 and kfakev0.0.0-20261013022759-7b6cac30a57a.go test -race -count=3passes, covering 84.8% ofqueue/kafka. It omits the metrics, franz-go's log adapter,Settings' godoc, and the special case for commits by a fenced-out member. Its span attributes are plain strings rather thansemconv. The stories implement this proposal rather than copying the prototype. Its tests are the ones under Testing.queue/queue.gokafka/settings.gokafka/service.gokafka/app.goReferences
queueandqueue/kafkakgo:Dialer,BlockRebalanceOnPoll,OnPartitionsRevoked,DisableAutoCommit,CommitRecords,LeaveGroupContextkfake:ListenFn,VirtualNetwork,TLS,Cluster.GroupInfokotel, not usedsemconv/v1.43.0/messagingconvretrymoduleAll reactions