1
0
mirror of https://github.com/redis/go-redis.git synced 2026-10-22 19:49:00 +03:00
Files
go-redis/autopipeline_fd_pipeline.go
ofekshenawa 48d65e5641 Metadata driven cluster routing (#4093)
* feat(command): add metadata-driven routing

* fix(cluster): preserve routing compatibility

* fix spellcheck ci

* fix(cluster): harden metadata routing

- Preserve legacy routing, hook order and typed results.
- Handle topology health and invalid metadata safely.
- Reject fan-out from ordered async pipelines before dispatch.
- Reduce allocations and duplicate routing work.
- Add regression coverage and document remaining limitations.

* fix(cluster): correct routing and cursor ownership

- Handle unavailable nodes and malformed topology replies
- Bound metadata verification and retry refreshes in the background
- Bind client-local cursor tokens to their originating nodes
- Validate routing policies and refresh topology after redirects
- Reduce routing allocations and extend tests and documentation

* fix spellcheck ci

* fix ci

* fix ci

* refactor(cluster): split cursor ownership

* fix cursor bug

* fix ci
2026-10-21 17:34:41 +03:00

427 lines
18 KiB
Go

package redis
import (
"context"
"errors"
"time"
"github.com/redis/go-redis/v9/internal/otel"
"github.com/redis/go-redis/v9/internal/pool"
)
// Batch submission on the full-duplex wire.
//
// WHY THIS EXISTS. AutoPipeliner.Pipeline() delegates to the underlying
// client, so a pipeline runs through processPipeline on a POOLED connection and
// never touches the full-duplex engine — the engine's only entry point is
// submit(), which takes one Cmder. That leaves go-redis without the shape
// rueidis offers as DoMulti: hand over N commands at once, block until every
// reply lands, on ONE multiplexed connection, spending no extra goroutines.
//
// The submit queue already has the primitive for it. fdQueue.pushBatch appends
// a whole slice under one lock, and because the queue is FIFO and the writer can
// only take contiguous prefixes, the commands stay ADJACENT on the wire — no
// other caller's command can land between them. That is a stronger guarantee
// than issuing N single commands gives, and it is what makes this a pipeline
// rather than a loop.
//
// Each command still carries its OWN completion batch. One shared batch would
// complete on the first reply, since the reader closes the batch per command, so
// the caller would return before the rest had landed.
// ErrFDPipelineUnavailable is returned when batch submission is requested on an
// autopipeliner that has no full-duplex engine. It is deliberately an error
// rather than a silent fallback to the pooled pipeline path: a caller asking for
// this wants the commands on the FD wire, and quietly running them somewhere
// else would be measured (or relied upon) as something it is not.
//
// Batch submission is STANDALONE ONLY. A full-duplex autopipeliner on a
// ClusterClient reports Config().FullDuplex true but runs one engine per node,
// and a pipeline can span nodes, so it gets this error and its Pipeline() keeps
// using the cluster pipeline, as it did before FD batches existed.
var ErrFDPipelineUnavailable = errors.New(
"redis: FDPipelined requires a full-duplex autopipeliner on a standalone Client")
// ErrFDPipelineDiverts is returned when a batch contains a command the engine
// cannot stream (blocking, per-command read timeout, runs-outside-pipeline,
// HIMPORT, or one the routing callback rejects). Such a command has to
// leave the pipe, which would break the batch's contiguity, so the whole call is
// refused instead of being silently split. A NoRetry command (e.g. RawWriteTo)
// is refused too: it makes the batch unreplayable as a unit, which the pipe's
// per-command recovery cannot honor.
var ErrFDPipelineDiverts = errors.New(
"redis: FDPipelined got a command that must be diverted off the full-duplex pipe")
// ErrFDPipelineSpansEngines is returned when FullDuplexPipes > 1 runs several
// full-duplex engines (pipes) and the batch's keyed commands hash to different ones.
// The batch can only ride one wire, and keeping per-first-key order needs each
// command on its own key's engine, so it is refused rather than split.
// Pipeline() falls back to an ordinary pipeline for such a batch.
var ErrFDPipelineSpansEngines = errors.New(
"redis: FDPipelined batch has keys on different full-duplex engines")
// FDPipelined submits cmds as one contiguous batch on the full-duplex
// connection and blocks until every reply has landed. It returns the first
// command error, Nil included, the same as Pipeline.Exec.
//
// Two differences from an ordinary pipeline. Retryable replies (LOADING and
// the like) are not retried here; Pipeline() retries the whole batch on top of
// this, see fdPipelineExec. And once the batch is admitted, ctx no longer bounds
// the wait: the commands share the held connection with other callers, so they
// cannot be abandoned mid-stream. ReadTimeout still bounds each reply.
//
// Compared with the two shapes that already exist:
//
// - Pipeline().Exec() batches up front and blocks, but on a pooled
// connection: a separate socket, its own round trip, no FD engine.
// - Submit()/the async face keeps N commands in flight on the FD wire with
// individual completion, but the caller issues them one at a time and they
// may interleave with other callers' commands.
//
// This is the third: batched up front, contiguous, blocking, on the FD wire.
//
// The commands are still completed individually by the reader, so after this
// returns each cmd carries its own result and error.
//
// Hooks and metrics match an ordinary pipeline: the client's
// ProcessPipelineHook chain runs around the batch (per-command ProcessHooks do
// not, as in an ordinary pipeline), and an OTel recorder gets one pipeline
// duration and, on failure, one pipeline-level error rather than per-command
// metrics. A Limiter is the exception: the FD writer asks it once per write
// chunk, and a chunk can mix this batch with other callers' commands, so a
// batch longer than MaxBatchSize (or MaxBatchBytes) can be partly admitted.
//
// EXPERIMENTAL: this API is subject to change, use with caution.
func (ap *AutoPipeliner) FDPipelined(ctx context.Context, cmds []Cmder) error {
if ap.fd == nil {
return ErrFDPipelineUnavailable
}
return ap.fd.client.wrapPipelineHooks(func(ctx context.Context, cmds []Cmder) error {
var start time.Time
if otel.GetPipelineOperationDurationCallback() != nil {
start = time.Now()
}
res, err := ap.fdPipelined(ctx, cmds)
if res.measured() {
ap.fdPipelineMetrics(ctx, start, cmds, max(res.attempts, 1), res.cn)
}
return err
})(ctx, cmds)
}
// wrapPipelineHooks folds the client's current hooks around next, in the same
// order the hook state builds its pipeline chain, so next runs exactly where
// processPipeline runs in an ordinary pipeline. It reads the live hook
// snapshot, so a hook added after the autopipeliner was built is honored.
func (hs *hooksMixin) wrapPipelineHooks(next ProcessPipelineHook) ProcessPipelineHook {
st := hs.state.Load()
for i := len(st.slice) - 1; i >= 0; i-- {
if wrapped := st.slice[i].ProcessPipelineHook(next); wrapped != nil {
next = wrapped
}
}
return next
}
// fdPipelineMetrics records an FD batch the way generalProcessPipeline records
// an ordinary pipeline: one duration with the command count and attempts, and
// one error when the pipeline as a whole failed. The reader skips its
// per-command metrics for pipelined commands, so nothing is counted twice.
//
// attempts counts the times the batch was issued. One difference from an
// ordinary pipeline: connection-lease retries are not counted. The lease
// belongs to the engine, not to the batch; a batch queued while the engine
// retries a lease waits and is issued once. So a batch issued after lease
// retries reports 1 attempt, and a batch failed because the lease retries ran
// out reports 0 retries.
func (ap *AutoPipeliner) fdPipelineMetrics(ctx context.Context, start time.Time, cmds []Cmder, attempts int, cn *pool.Conn) {
perr := fdPipelineLevelErr(cmds)
db := ap.fd.client.opt.DB
ap.fd.emitMetricsGuarded(ctx, func() {
// A zero start means no duration callback existed when the operation
// began (a recorder installed mid-flight). Record no duration then, as
// an ordinary pipeline does, rather than time.Since(time.Time{}).
if cb := otel.GetPipelineOperationDurationCallback(); cb != nil && !start.IsZero() {
cb(ctx, time.Since(start), "PIPELINE", len(cmds), attempts, perr, cn, db)
}
if perr != nil {
if errorCallback := pool.GetMetricErrorCallback(); errorCallback != nil {
errorType, statusCode, isInternal := classifyCommandErrorGuarded(perr)
errorCallback(ctx, errorType, cn, statusCode, isInternal, attempts-1)
}
}
})
}
// fdPipelineLevelErr is the error an ordinary pipeline would report for the
// batch: the first transport (non-Redis) error, else the first command's reply
// error. pipelineReadCmds returns exactly that.
func fdPipelineLevelErr(cmds []Cmder) error {
if err := fdPipelineTransportErr(cmds); err != nil {
return err
}
if len(cmds) > 0 {
return cmds[0].rawErr()
}
return nil
}
// fdPipelineTransportErr returns the first error in cmds that is not a reply
// from the server: a connection, protocol or push-drain failure, classified
// as the reader classifies a fatal reply (fdReplyIsFatal). nil when every
// command got a reply.
func fdPipelineTransportErr(cmds []Cmder) error {
for _, cmd := range cmds {
if e := cmd.rawErr(); e != nil && (errors.Is(e, errFDPushDrainFailed) || !isRedisError(e)) {
return e
}
}
return nil
}
// fdPipeResult is what fdPipelined reports about a batch it ran.
type fdPipeResult struct {
// attempts is how many times the engine issued the batch: 1, plus one per
// connection-error replay (the highest across its commands); 0 when nothing
// was admitted.
attempts int
// cn is the held connection its replies came back on.
cn *pool.Conn
// flushed reports that the Close-time flush ran every batch of it through
// the pooled pipeline, which already recorded the pipeline metric.
flushed bool
// rejected reports that it was refused before admission (closed, or ctx
// done), so nothing ran.
rejected bool
}
// measured reports whether the caller records the pipeline metric: for a
// batch that ran on the FD wire, or one refused before admission (an
// ordinary pipeline records that case when withPipelineConn fails), but not
// for one the Close-time flush measured in full.
func (r fdPipeResult) measured() bool {
return !r.flushed && (r.attempts > 0 || r.rejected)
}
// fdPipelineAllFlushed reports whether the Close-time flush ran every batch of
// a pipeline. Only then did the pooled flush record the whole pipeline. When
// it ran only an unread tail, it recorded that tail, and the FD metric still
// covers the whole operation.
func fdPipelineAllFlushed(batches []*apBatch) bool {
if len(batches) == 0 {
return false
}
for _, b := range batches {
if !b.fdFlushed {
return false
}
}
return true
}
// fdPipelined runs an eligible batch on the FD path.
func (ap *AutoPipeliner) fdPipelined(ctx context.Context, cmds []Cmder) (fdPipeResult, error) {
if len(cmds) == 0 {
return fdPipeResult{}, nil
}
// A redirect-aware engine is a cluster node child. It is reachable (the
// child is the node client's cached async autopipeliner, which
// ForEachMaster hands out), but it follows MOVED/ASK by re-running ONE
// command off the pipe, which would let the rest of the pipeline run ahead
// of it. Such a batch keeps the node client's ordinary pipeline, as before.
if ap.fd == nil || ap.fd.redirectAware {
return fdPipeResult{}, ErrFDPipelineUnavailable
}
// Refuse a batch containing anything that would leave the pipe. Checked for
// EVERY command before anything is submitted, so the call either goes as one
// contiguous unit or not at all — a half-submitted batch would have the
// diverted command complete out of order relative to its neighbours.
for _, cmd := range cmds {
if cmd == nil {
return fdPipeResult{}, ErrFDPipelineDiverts
}
if ap.routeCommand != nil {
if divert, err := ap.routeCommand(ctx, cmd); divert || err != nil {
return fdPipeResult{}, ErrFDPipelineDiverts
}
}
if cmd.readTimeout() != nil || runsOutsidePipeline(cmd.Name()) ||
isBlockingCmd(cmd) || isHImportCmd(cmd) {
return fdPipeResult{}, ErrFDPipelineDiverts
}
// A NoRetry command makes the whole batch unreplayable: an ordinary
// pipeline holding one retries nothing after a connection error. The FD
// tail recovery replays per command, up to the first sent NoRetry one,
// so the commands before it could run twice. Keep such a batch off the
// pipe.
if fdNoRetrySafe(cmd) {
return fdPipeResult{}, ErrFDPipelineDiverts
}
}
// The WHOLE batch goes to ONE engine: a pipeline split across engines would
// lose its internal order, which is the one thing a pipeline guarantees. That
// engine must also be the one every keyed command hashes to, or a command
// could run ahead of an earlier, unawaited command for the same key on that
// key's engine. A batch that spans engines is refused.
e, ok := ap.fdForBatch(cmds)
if !ok {
return fdPipeResult{}, ErrFDPipelineSpansEngines
}
batches, err := e.submitBatch(ctx, cmds)
if err != nil {
return fdPipeResult{}, err
}
if batches == nil {
// Submit-time rejection (closed, or ctx expired while backpressured).
// Every command carries its own error; report the first.
return fdPipeResult{rejected: true}, cmdsFirstErr(cmds)
}
// Mirror submit()'s contract on the deferred face: the batch is installed on
// the command so its result accessors self-gate, which matters for a caller
// that keeps the Cmder after this returns.
if !ap.blocking {
for i, cmd := range cmds {
cmd.setReady(batches[i])
}
}
var first error
res := fdPipeResult{attempts: 1}
for i, cmd := range cmds {
// AutoFuture.Wait carries the executor-goroutine self-deadlock guard, so
// waiting through it rather than on batch.done keeps a pipeline hook that
// calls this from behaving differently than it does elsewhere.
if werr := (AutoFuture{cmd: cmd, batch: batches[i]}).Wait(); werr != nil && first == nil {
first = werr
}
// Wait returned, so the reader's stamp is visible.
if a := batches[i].fdAttempts; a > res.attempts {
res.attempts = a
}
if c := batches[i].fdConn; c != nil {
res.cn = c
}
}
res.flushed = fdPipelineAllFlushed(batches)
// A transport failure comes first, as in processPipeline: it tells the
// caller the later commands have no known result.
if err := fdPipelineTransportErr(cmds); err != nil {
return res, err
}
return res, first
}
// submitBatch enqueues cmds as one contiguous run and returns their completion
// batches, one per command. A nil slice with a nil error means submit-time
// rejection, with each command's own error already set (same convention as
// submit()'s completedBatch return).
//
// Admission is all-or-nothing: pushBatch either takes the whole slice or takes
// none of it, so the run cannot be split across two waves by a queue that fills
// halfway through.
//
// A refused batch holds its slots while it waits (fdQueue.hold). Without that a
// batch longer than the writer's wave could starve: each take frees at most a
// wave, single submitters refill any free slot, and the batch never sees its
// whole length free at once. With the reservation the freed room accumulates
// for the batch, and the singles resume once it is admitted (or gives up).
// Holders are admitted in arrival order: a later, smaller batch that could be
// admitted with the room the head is waiting for would otherwise starve the
// head.
func (fd *fdEngine) submitBatch(ctx context.Context, cmds []Cmder) ([]*apBatch, error) {
// A batch larger than the queue itself can NEVER be admitted, and the
// room-wait loop below would spin forever waiting for space that cannot
// exist. Report it instead of hanging: the caller can split, or raise
// FullDuplexWindow.
if n := fd.q.capacity(); n > 0 && len(cmds) > n {
return nil, ErrFDPipelineTooLarge
}
if fd.ap.isClosed() {
setCmdsErr(cmds, ErrClosed)
return nil, nil
}
// No per-command ProcessHooks here: the batch runs inside the client's
// ProcessPipelineHook chain (FDPipelined, fdPipelineHooked), and an ordinary
// pipeline runs only that chain, not a ProcessHook per command.
reqs := make([]fdReq, len(cmds))
batches := make([]*apBatch, len(cmds))
for i, cmd := range cmds {
b := newAPBatch() // never pooled: pooled batches are the blocking face's single-waiter signal
b.fdAttempts = 1 // the first issue; replays and the reader raise it
b.fdGroup = batches[0]
if i == 0 {
b.fdGroup = b
}
batches[i] = b
reqs[i] = fdReq{cmd: cmd, batch: b, ctx: ctx, attempts: 1, pipelined: true}
}
// held: this batch holds len(reqs) reserved slots under id and waits on
// wake, which the queue signals only while this batch is the head of the
// holder line (and on close). Every exit that does not admit the batch must
// give the slots back, after the RLock is dropped.
held, id := false, uint64(0)
var wake <-chan struct{}
reject := func(err error) ([]*apBatch, error) {
fd.submitMu.RUnlock()
if held {
fd.q.unhold(id)
}
setCmdsErr(cmds, err)
return nil, nil
}
fd.submitMu.RLock()
for {
if fd.closed {
return reject(ErrClosed)
}
// A done ctx is refused before admission, not only while waiting for
// room: once admitted the batch runs, and an ordinary pipeline rejects a
// canceled call before writing anything.
if cerr := ctx.Err(); cerr != nil {
return reject(cerr)
}
var res fdPushResult
if held {
res = fd.q.pushHeld(reqs, id) // consumes the reservation on success
} else {
res = fd.q.pushBatch(reqs)
}
switch res {
case fdPushOK:
fd.submitMu.RUnlock()
return batches, nil
case fdPushClosed:
return reject(ErrClosed)
}
// Queue full. Reserve the batch's slots, then retry at once rather than
// sleeping: a take between the refused push and the hold saw no holder
// to wake, and if it emptied the queue there would be no later take to
// wake this batch. The retry under the reservation either fits, or
// observes a queue that still holds work (so a take, which wakes the
// head, is guaranteed), or finds another holder ahead (whose departure
// wakes the new head).
if !held {
id, wake = fd.q.hold(len(reqs))
held = true
continue
}
// Wait to be woken as the head of the line (a take freed room, or the
// previous head left), then retry the whole batch. The wake is this
// batch's own cap-1 channel, so it cannot be taken by another waiter and
// a wake that lands while this batch is mid-retry is kept for the next
// wait. Same release conditions as submit()'s backpressure wait.
select {
case <-wake:
case <-ctx.Done():
return reject(ctx.Err())
case <-fd.ap.ctx.Done():
return reject(ErrClosed)
}
}
}
// ErrFDPipelineTooLarge is returned when a batch cannot fit the submit queue
// even when the queue is empty.
var ErrFDPipelineTooLarge = errors.New(
"redis: FDPipelined batch is larger than the full-duplex submit queue")