Sticky selection needs a request key, which only the balancer sees at dispatch time. Today you wire session affinity end to end - Balance routes by the request's key, so a client keeps landing on the same backend through the full transport flow.
Add a key-aware Balance that routes a request by its session key when the selector is sticky, staying stable as the pool changes.
The sticky selector you built in the selection chapter needs one thing the bare
Select signature never carried: the request’s session key. That key only
becomes available when a real request arrives, so affinity has to be threaded through
the dispatch layer. Balance is that key-aware entry point - it checks whether its
selector is a KeyedSelector and, if so, routes by req.Key; otherwise it falls
back to the ordinary stateless pick. A type assertion keeps the stateless algorithms
untouched while giving sticky selection the key it needs.
With this, everything you built now composes into one call: Balance selects
(stateless or sticky), leases the connection, forwards through the transport, records
the outcome for passive health, and releases - the complete request path. The pins
confirm affinity survives the round trip: u1 sticks to A across calls and even
after D joins the pool, while the active counts still return to 0 because each
lease is released. This is the public surface the finalize pass will put behind a real
TCP listener.
type KeyedSelector interface{ SelectFor(key string) (*Backend, error) }func (bal *Balancer) Balance(req Request) (Response, error) {var b *Backend; var err errorif ks, ok := bal.sel.(KeyedSelector); ok {b, err = ks.SelectFor(req.Key) // sticky path} else {b, err = bal.sel.Select() // stateless path}if err != nil { return Response{}, err }b.Incr(); defer b.Decr()resp, terr := bal.transport(b, req)if terr != nil { b.RecordFailure() } else { b.RecordSuccess() }return resp, terr}