Files
go-redis/autopipeline_fdqueue.go
Nedyalko DyakovandVladislav Kotsev 349bcf47a9 feat(autopipeline): run Pipeline on the full-duplex connection (#4021)
* feat(autopipeline): add fdQueue.pushBatch for FD batches

FDPipelined submits a pipeline as one contiguous run; pushBatch enqueues
it under a single lock, all or nothing.

* feat(autopipeline): run Pipeline on the full-duplex connection

ap.Pipeline() delegated to the client's pipeliner, so a pipeline built
from a full-duplex autopipeliner quietly left the held connection, took a
pooled one per Exec, and lost the cross-caller coalescing the engine
exists for. It now submits the batch to the engine as one contiguous run.

FDPipelined does the submission: the batch is admitted all-or-nothing, so
its commands occupy consecutive in-flight slots and their replies come
back in submit order, which keeps a pipeline's internal ordering exactly
as a dedicated connection would. Batches that cannot ride the wire -- a
blocking command, a per-command read timeout, anything the divert
predicate claims, or a batch larger than the queue -- fall back to the
ordinary pipeline path.

Measured on a 14-core client against a local server, 10 commands per
Exec, 3 passes:

  callers   pooled       on the engine   active sockets
      256   586,557        871,469       149 -> 1
     1024   516,042        785,191       150 -> 1

So +48.6% and +52.2%, on one socket instead of 149.

Above ~2048 callers a single engine saturates its submit queue and
admission serializes; spreading pipelines across several auto-pipeliners
avoids that (1.6M ops/s across eight), which is an argument for a
supported multi-connection full-duplex mode rather than for this commit.

TxPipeline is deliberately untouched: MULTI/EXEC needs connection
affinity for the whole transaction, which the engine does not offer.

* fix(autopipeline): keep FD pipeline Exec semantics

Pipeline() on a full-duplex autopipeliner runs the batch on the held
connection. Three things differed from an ordinary pipeline:

- A retryable reply (LOADING and the like) was retried per command off
  the reader, so later commands of the pipeline could run before it.
  Pipelined commands now settle retryable replies inline, and a
  retryable reply on the first command re-runs the whole batch the
  ordinary way, as processPipeline does.
- With ContextTimeoutEnabled the caller's deadline did not bound the
  wait. Such clients keep the pooled pipeline path.
- Exec dropped redis.Nil. It now returns the first error, Nil included.

FDPipelined is documented as standalone only.

* fix(autopipeline): close FD pipeline Exec gaps

Pipeline() on a full-duplex autopipeliner still differed from an
ordinary pipeline:

- Hooks, a Limiter or an OTel recorder: an ordinary pipeline runs the
  ProcessPipelineHook chain, takes one Limiter decision per batch and
  records one pipeline duration. The FD path did none of that, so such
  clients now keep the pooled pipeline. Running the FD path under these
  is a follow-up.
- The whole-batch retry on a retryable first reply re-ran pipelines
  holding a NoRetry command. It now stops on cmdsContainNoRetry, as
  generalProcessPipeline does.
- That retry also restarted the full MaxRetries budget, so MaxRetries:1
  allowed three executions. The FD attempt now counts as the first: the
  re-run gets MaxRetries-1 retries, after one backoff.
- A canceled ctx was only checked while waiting for queue room, so a
  pre-canceled Exec still ran. submitBatch now refuses a done ctx before
  admission.

* fix(autopipeline): count FD replays in the retry budget

The whole-batch retry assumed the FD engine issued the pipeline once.
After a connection-error replay it had issued it twice, so a lost first
write, LOADING on the replay and the pooled re-run made three executions
under MaxRetries:1, where an ordinary pipeline stops at two.

The reader now stamps each pipelined command's attempt count on its
batch before completing it, fdPipelined reports the highest, and the
re-run gets MaxRetries minus that, or none when it is spent.

* fix(autopipeline): keep FD pipelines off cluster children

A cluster node's FD child is the node client's cached async
autopipeliner, so ForEachMaster hands it to users, and its engine is
redirect-aware: it follows MOVED/ASK by re-running one command off the
pipe. A Pipeline() on it would let the rest of the batch run ahead of a
redirected command. FDPipelined now refuses redirect-aware engines, so
such a pipeline keeps the node client's ordinary pipeline, as before.

* fix(autopipeline): keep NoRetry batches off the FD pipe

An ordinary pipeline holding a NoRetry command retries nothing after a
connection error. The FD tail recovery replays per command, up to the
first sent NoRetry one, so a pipeline like SET k; RawWriteTo(GET x)
that lost its connection ran the SET twice. FDPipelined now refuses a
batch with a NoRetry command, and Pipeline() runs it the ordinary way.

The pipeline retry budget read fdAttempts, which only the reader
stamped. A command that was replayed and then failed carried no count,
so a pooled re-run could exceed MaxRetries. The batch is stamped at
submit and on every replay.

* fix(autopipeline): run pipeline hooks and metrics on FD

Pipeline() and FDPipelined kept away from the full-duplex path whenever
hooks, a Limiter or an OTel recorder were set, or bypassed them. They
now run on it:

- The batch runs inside the client's ProcessPipelineHook chain, with
  fdPipelineExec as its terminal where processPipeline sits in an
  ordinary pipeline. Fallback and the whole-batch retry happen inside
  the chain, so hooks run once. Batched commands get no per-command
  ProcessHook, as in an ordinary pipeline.
- An FD batch records one pipeline duration and, on failure, one
  pipeline-level error; the reader skips its per-command metrics for
  batched commands.
- A Limiter stays asked per write chunk, which can partly admit a batch
  longer than MaxBatchSize. Documented on FDPipelined.

Only ContextTimeoutEnabled still keeps the pooled pipeline.

* fix(autopipeline): no FD pipeline re-run after a replay

The whole-batch retry re-ran a pipeline whose first command got a
retryable reply even when the engine had already replayed its unread
tail after a connection error, so a command in that tail could run
three times where an ordinary pipeline runs it twice. It now re-runs
only a batch the engine issued once. The Close-time flush stamps the
batch as issued again, so a flushed batch is not re-run either.

The re-run continues the FD attempt's measurement (start and attempts)
through processPipelineRetriesAfter, so one pipeline metric covers the
whole operation, and a ctx that ends in the backoff still records it.

* fix(autopipeline): keep flushed FD pipelines whole

When Close wins against an admitted FD pipeline, shutdownFlush sends
its commands through flushReqs. That split them at MaxBatchSize and at
attempt boundaries, so a retryable first reply retried only the first
chunk. Every batch of one FD pipeline now points at its first batch
(fdGroup), and the flush keeps that run in one chunk, so the pooled
retry re-runs the pipeline as a whole.

The pooled flush already records the pipeline metric. fdPipelined now
reports that the flush ran the batch, and FDPipelined/fdPipelineExec
skip their own metric in that case. failQueue skips the per-command
error for pipelined commands, as failReqs does, since fdPipelineExec
reports the batch once.

* fix(autopipeline): FD batch errors and queue wait

An FD pipeline now returns a transport or protocol failure before any
Redis error, as processPipeline does, so a caller that checks only
Exec's error sees that later commands have no known result.

The whole-batch re-run for a retryable first reply is also ruled out
when any command in the batch failed that way. The engine does not
replay such a command, since it may already have run, so the re-run
could execute it twice; an ordinary pipeline does not retry there.

A batch waiting for more room than one writer take freed put the room
signal back, took it straight back, failed to fit, and spun holding
submitMu.RLock. It now passes a wake it cannot use on once per take,
which keeps the wake chain for smaller waiters without spinning.

* fix(autopipeline): FD pipeline metrics and backoff

The pooled re-run after an FD attempt restarted its retry backoff at the
first step. generalProcessPipelineFrom now continues it from the prior
attempts, so a pipeline sleeps the same steps an ordinary one does.

A pipeline refused before admission (ctx done, or closed) recorded no
pipeline metric; an ordinary pipeline records one when withPipelineConn
fails. It now records one with a single attempt.

The Close flush marked every batch flushed before running any, so a
batch it never ran (a dead endpoint fails the rest) recorded nothing.
It now marks a batch where its chunk runs. And the FD metric is skipped
only when the flush ran the whole pipeline: when it ran only an unread
tail, the FD metric still covers the whole operation.

* fix(autopipeline): skip FD duration with no start

The start time is read only when a pipeline duration callback exists.
A recorder installed while an FD pipeline ran found a zero start, and
fdPipelineMetrics reported time.Since(time.Time{}), a duration of
centuries. It now records no duration for that operation, as an
ordinary pipeline does, which reads the callback once at the start.

Also pin that CSC state commands (SELECT, AUTH, HELLO with a protocol,
RESET, CLIENT TRACKING) never ride the FD pipe: they leave it as
runsOutsidePipeline commands, so Pipeline() falls back to the ordinary
pipeline and its CSC guard, and FDPipelined refuses the batch.

* fix(autopipeline): Close flush budget and retry timing

An FD pipeline flushed on Close can carry commands with different
attempt counts, and runs as one pipeline. It took the retry budget of
its first command, so a later command with more attempts could run past
its MaxRetries+1 executions. It now gets the smallest budget of its
commands, and fails with errFDRetryBudgetExhausted when any has none
left. A command with more budget may get fewer retries than it alone
would, as in an ordinary pipeline, whose budget covers the whole batch.

The pooled re-run of an FD operation that began with no duration
callback (zero start) started its clock at the re-run when a recorder
was installed in between, so the duration covered only the tail while
the count included the FD attempt. generalProcessPipelineFrom now
records no duration for a continued operation with no start.

* fix(autopipeline): keep flush budget groups separate

flushCarryBudgeted grew a group while the next command had the same
attempt count or belonged to the same FD pipeline, so a command in front
of a pipeline joined that pipeline's budget group. The group's smallest
budget then applied to it, and a pipeline command with no budget left
failed the unrelated command too. A group is now one FD pipeline, or a
run of other commands with the same attempt count, never both.

* fix(autopipeline): stamp flush issues where they run

shutdownFlush stamped every queued pipelined batch with one more issue
before either flush ran. When the carry flush hit a dead endpoint the
fresh queue was only failed, never issued, yet its callers saw an issue
count of 2 and reported a retry that never happened. flushReqs now
stamps the issue, with fdFlushed, where it runs a chunk. A batch that
never runs keeps its count; the transport error it is failed with
already rules out a re-run in fdPipelineExec.

* fix(autopipeline): flush a split FD pipeline whole

When the writer took only a prefix of an FD pipeline before the session
failed, Close found the prefix in the carry and the tail still queued,
and flushed them as two pooled pipelines. A retryable first reply then
retried only the prefix, and the tail ran under a full budget of its
own. shutdownFlush now moves the queued tail into the carry, so the
pipeline runs as one at the smallest remaining budget of its commands.

* docs(autopipeline): note lease retries in FD metrics

A full-duplex pipeline's attempts count the times the batch was issued.
Connection-lease retries belong to the engine: a batch queued during
them waits and is issued once, so they are not counted, where an
ordinary pipeline counts each failed connection acquisition. Say so on
fdPipelineMetrics.

* fix(autopipeline): reserve queue room for FD batches (#4057)

* fix(autopipeline): reserve queue room for FD batches

* fix(autopipeline): FD batch head-of-line starvation

* fix(autopipeline): remove unused function

* fix(autopipeline): fail a split FD pipeline whole

Live connection-error recovery partitions the unacked tail per command
(budget spent, after a sent NoRetry, not replayable) and fails part of
it. When the writer had taken only a prefix of an FD pipeline, the rest
of that pipeline, kept for replay or still queued, ran on the next
connection, and Exec reported a transport error for a pipeline whose
later commands executed. A pipeline on a dedicated connection sends
nothing more once it has failed. failPipelines now fails the whole
pipeline: its members kept for replay and its tail at the queue head.

---------

Co-authored-by: Vladislav Kotsev <vladygk8@gmail.com>
2026-10-02 16:15:05 +03:00

607 lines
20 KiB
Go

package redis
import "sync"
// fdQueue is the full-duplex submit queue: an MPSC hand-off from many submitting
// goroutines to the single writer goroutine.
//
// It replaces a buffered `chan fdReq`. The channel cost, per command:
//
// producer lock + 104-byte copy + unlock, plus a goready EVERY time an
// arrival found the writer parked
// writer lock + 104-byte copy + unlock, ONCE PER COMMAND
//
// At ~450k ops/s that measured 3.41 s in the submit send, 2.65 s in the writer's
// receive and 3.25 s in the accumulate select (19.6% of all CPU between them).
//
// The slice costs one lock and one append per command on the producer side, and
// ONE lock plus ONE bulk copy for the whole wave on the writer side — the
// writer's per-command cost disappears. pushBatch amortises the producer lock
// across k commands as well.
//
// The structural win is not the copy, it is the parking. A channel can only
// express "wake me on the next arrival", so an accumulating writer was woken
// once per command and re-entered its select ~112 times per 250 us window. This
// queue lets the writer say "wake me when the queue holds N more, or not at
// all" (park), so an accumulating writer parks and wakes ONCE per flush. A
// submitter that finds the writer awake, or the depth still short of what the
// writer asked for, does no channel work at all.
//
// Ordering is FIFO, so the full-order (T1) guarantee is unchanged. The one
// semantic loss versus a channel is fairness between submitters blocked on a
// FULL queue: chansend queues blocked senders FIFO, whereas here they re-contend
// for the mutex. Per-goroutine program order is still exact (a goroutine's push
// happens-before its next push), which is what the ordering contract actually
// promises; cross-goroutine arrival order under saturation was never defined.
//
// Re-contention is fair enough between single commands, which all need one
// slot, but not between a single command and a BATCH (pushBatch), which needs
// its whole length free at one instant. The writer frees at most a wave per
// take while singles refill any free slot, so under sustained load a batch
// longer than a wave would never see enough room. A batch that is refused
// therefore RESERVES its slots (hold): reserved slots count as occupied for
// every other push, so the writer's takes accumulate room for the batch
// instead of handing it straight back to the singles. Holders form a FIFO
// line and only the HEAD can be admitted (pushHeld): a later, smaller batch
// would otherwise refill the head's room exactly as the singles did. Each
// holder has its own wake channel and only the head is ever signalled, so no
// wake is shared, passed along, or lost between waiters.
type fdQueue struct {
mu sync.Mutex
// buf[head:] is the live queue. takeInto advances head instead of shifting
// the remainder down, so a take costs O(batch) rather than O(queue depth):
// at a full default queue (65536) a shift moved ~6.8 MB under this lock per
// 200-command wave. The dead prefix is reclaimed when the queue empties, or
// by compaction once it is at least as long as the live part, so a
// compaction never copies more than was taken since the last one. Until
// then an append that outgrows buf grows it (doubling), so buf can reach
// about twice the live depth.
buf []fdReq
head int
max int // bound; mirrors the old channel capacity
// holders are the batches that were refused for lack of room and are
// waiting to be admitted, in arrival order (see hold/pushHeld). reserved
// is the sum of their sizes; it counts as occupied for push and for a
// non-holding pushBatch, so neither a single command nor a new batch can
// refill the room the writer frees ahead of a waiting batch. Only
// holders[0] can be admitted, and it needs just its own length free: the
// sum of reservations may exceed the capacity, so a rule that made the
// head wait for ALL reservations to fit could never be satisfied. Every
// event that may let the head in (a take, a drain, the previous head
// leaving) signals holders[0].wake; closeQueue signals every holder.
holders []fdHolder
reserved int
nextID uint64
// parked/wakeAt are the writer's standing request: it is asleep and wants a
// signal once the queue holds at least wakeAt entries. Only a submitter that
// takes parked from true to false sends the signal, so a wave of arrivals
// produces exactly one wake.
parked bool
wakeAt int
wake chan struct{} // cap 1: submitter -> parked writer
room chan struct{} // cap 1: writer -> single submitters blocked on a full queue
closed bool
}
// fdQueueReady is an always-ready receive. The writer selects on it instead of
// the wake channel when park() reported that work is already queued, so the
// "already had work" and "waited for work" paths share one select and one copy
// of the batch-building code.
var fdQueueReady = func() chan struct{} {
c := make(chan struct{})
close(c)
return c
}()
// fdHolder is a batch waiting for room with a reservation of n slots. The id
// identifies it to pushHeld and unhold. wake (cap 1) is signalled when the
// holder is at the head of the line and may fit, and on close. A holder behind
// the head is never woken by room events: nothing it could do with the wake.
type fdHolder struct {
id uint64
n int
wake chan struct{}
}
// fdPushResult is the outcome of an enqueue attempt.
type fdPushResult int
const (
fdPushOK fdPushResult = iota
fdPushFull // bound reached; caller waits on roomCh and retries
fdPushClosed // engine shut down; caller fails the command
)
func newFDQueue(max int) *fdQueue {
if max < 1 {
max = 1
}
return &fdQueue{
buf: make([]fdReq, 0, 64), // grows to the live depth, not to max up front
max: max,
wake: make(chan struct{}, 1),
room: make(chan struct{}, 1),
}
}
// NIL-QUEUE CONTRACT. Every method below tolerates a nil receiver, and together
// they reproduce the semantics of the nil `chan fdReq` this type replaced: a nil
// channel is never ready, so a receive on it blocks forever and a send on it
// blocks forever. That is not a defensive flourish — fdEngine is constructed as a
// bare struct literal in several tests that exercise session()/attempt() without
// a queue, and those tests depend on the submit arm simply never firing. So:
//
// wakeCh/roomCh nil channel -> that select arm is never ready
// park true -> "parked", and the nil wakeCh never wakes us
// takeInto no-op -> no work ever arrives
// push fdPushFull -> submit waits on the nil roomCh, i.e. forever,
// leaving only its ctx.Done() arms, exactly as a
// blocked send on a nil channel did
func (q *fdQueue) wakeCh() <-chan struct{} {
if q == nil {
return nil
}
return q.wake
}
func (q *fdQueue) roomCh() <-chan struct{} {
if q == nil {
return nil
}
return q.room
}
// capacity is the queue bound, 0 for a nil queue.
func (q *fdQueue) capacity() int {
if q == nil {
return 0
}
return q.max
}
// live is the number of queued commands. Caller holds mu.
func (q *fdQueue) live() int { return len(q.buf) - q.head }
// compact moves the live queue to the front of buf and clears the vacated tail,
// so no stale fdReq pins a Cmder. Caller holds mu.
func (q *fdQueue) compact() {
if q.head == 0 {
return
}
n := copy(q.buf, q.buf[q.head:])
clear(q.buf[n:])
q.buf = q.buf[:n]
q.head = 0
}
// fdQueueRetainCap bounds the backing array an EMPTY queue keeps. A burst (a
// stalled peer backing the queue up to the window, 65536 by default) grows buf
// to several MiB; keeping that for the life of an idle engine would pin the
// burst's high-water memory. A queue that routinely runs deeper than this is
// re-grown by append, which doubles, so the release is not paid per wave.
const fdQueueRetainCap = 4096
// resetEmpty starts an empty queue over from slot 0, releasing a backing
// array a burst grew past fdQueueRetainCap. Caller holds mu and has already
// cleared the slots.
func (q *fdQueue) resetEmpty() {
if cap(q.buf) > fdQueueRetainCap {
q.buf = make([]fdReq, 0, 64)
} else {
q.buf = q.buf[:0]
}
q.head = 0
}
// reserve compacts before an append that would outgrow buf, when the dead
// prefix is long enough to pay for it; otherwise the append grows buf.
// Caller holds mu.
func (q *fdQueue) reserve(k int) {
if len(q.buf)+k > cap(q.buf) && q.head >= q.live() {
q.compact()
}
}
// signalRoom re-arms the cap-1 room signal. Called by a submitter that just
// woke from a full queue, to chain the wake to the next one waiting, and by a
// holder whose reservation stops counting against the singles.
func (q *fdQueue) signalRoom() {
if q == nil {
return
}
select {
case q.room <- struct{}{}:
default:
}
}
// signalHolder wakes one holder. Non-blocking on its cap-1 channel, so a
// pending wake is not doubled; nil (no holder) is a no-op. Called OUTSIDE mu.
func signalHolder(ch chan struct{}) {
if ch == nil {
return
}
select {
case ch <- struct{}{}:
default:
}
}
// headWakeLocked returns the wake channel of the head of the holder line, or
// nil when the line is empty. Caller holds mu; the send happens after unlock.
func (q *fdQueue) headWakeLocked() chan struct{} {
if len(q.holders) == 0 {
return nil
}
return q.holders[0].wake
}
// signalRoomAndHead releases the waiters a take or drain may have made room
// for: one single on room, and the head of the holder line on its own wake.
// Called OUTSIDE mu with head read under it.
func (q *fdQueue) signalRoomAndHead(head chan struct{}) {
q.signalRoom()
signalHolder(head)
}
// hold reserves k slots for a batch that was refused for lack of room and
// puts it at the back of the holder line. Until the holder is admitted
// (pushHeld) or gives up (unhold), the slots count as occupied for every other
// push, so the room the writer frees accumulates for the holders. k is bounded
// by the caller to the queue capacity. Returns the holder's id and the channel
// it must wait on; on a nil queue both are zero and the nil channel never
// fires (see the nil-queue contract).
func (q *fdQueue) hold(k int) (id uint64, wake <-chan struct{}) {
if q == nil || k <= 0 {
return 0, nil
}
ch := make(chan struct{}, 1)
q.mu.Lock()
q.nextID++
id = q.nextID
q.holders = append(q.holders, fdHolder{id: id, n: k, wake: ch})
q.reserved += k
q.mu.Unlock()
return id, ch
}
// unhold releases the reservation of a holder that gave up (ctx done,
// shutdown). It wakes a single the reservation may have been holding back,
// and, if the holder was at the head of the line, the new head, which may fit.
// A holder behind the head leaves without a trace: it was never signalled, so
// no wake is lost with it.
func (q *fdQueue) unhold(id uint64) {
if q == nil {
return
}
q.mu.Lock()
wasHead, found := q.dropHolderLocked(id)
wakeSingles := found && q.live()+q.reserved < q.max
var head chan struct{}
if wasHead {
head = q.headWakeLocked()
}
q.mu.Unlock()
if wakeSingles {
q.signalRoom()
}
signalHolder(head)
}
// dropHolderLocked removes the holder with id from the line and releases
// its slots. Caller holds mu.
func (q *fdQueue) dropHolderLocked(id uint64) (wasHead, found bool) {
for i, h := range q.holders {
if h.id != id {
continue
}
q.reserved -= h.n
q.holders = append(q.holders[:i], q.holders[i+1:]...)
if len(q.holders) == 0 {
q.holders = nil // let a burst's line be collected
}
return i == 0, true
}
return false, false
}
// roomFor reports whether n more single commands fit, net of reservations.
func (q *fdQueue) roomFor(n int) bool {
if q == nil {
return false
}
q.mu.Lock()
ok := q.max-q.live()-q.reserved >= n
q.mu.Unlock()
return ok
}
// push enqueues one command. The wake signal is sent OUTSIDE the mutex: it is a
// non-blocking send on a cap-1 channel, but it can call goready, and holding the
// submit mutex across a scheduler operation would convoy every other submitter
// behind it.
func (q *fdQueue) push(req fdReq) fdPushResult {
if q == nil {
return fdPushFull // see the nil-queue contract
}
q.mu.Lock()
if q.closed {
q.mu.Unlock()
return fdPushClosed
}
if q.live()+q.reserved >= q.max {
q.mu.Unlock()
return fdPushFull
}
q.reserve(1)
q.buf = append(q.buf, req)
signal := q.parked && q.live() >= q.wakeAt
if signal {
q.parked = false // claim the wake: later arrivals in this wave stay silent
}
q.mu.Unlock()
if signal {
q.signalWake()
}
return fdPushOK
}
// pushBatch enqueues reqs under a SINGLE lock, for callers that already hold a
// run of commands (a pipeline, or a carry tail being returned to the queue).
// All-or-nothing: a partial enqueue would split a pipeline across two flushes
// and, worse, leave the caller unsure which half it owns. Slots reserved by
// waiting holders count as occupied; a refused caller that wants to wait
// should hold its own slots and retry through pushHeld.
func (q *fdQueue) pushBatch(reqs []fdReq) fdPushResult {
if len(reqs) == 0 {
return fdPushOK
}
if q == nil {
return fdPushFull // see the nil-queue contract
}
q.mu.Lock()
if q.closed {
q.mu.Unlock()
return fdPushClosed
}
if q.live()+q.reserved+len(reqs) > q.max {
q.mu.Unlock()
return fdPushFull
}
signal := q.appendLocked(reqs)
q.mu.Unlock()
if signal {
q.signalWake()
}
return fdPushOK
}
// pushHeld is pushBatch for the holder with id, whose reservation is
// len(reqs) slots. Only the head of the holder line is admitted; anyone else
// is refused (fdPushFull) and waits on its own wake, which fires once it has
// become the head and may fit. The head needs only its own length free: the
// slots behind it are reserved, not occupied, and counting them would let the
// line deadlock once the reservations outgrow the capacity. On admission the
// reservation is consumed, a single the reservation was holding back is woken
// if room remains, and the next holder is woken to try in turn.
func (q *fdQueue) pushHeld(reqs []fdReq, id uint64) fdPushResult {
if q == nil {
return fdPushFull // see the nil-queue contract
}
q.mu.Lock()
if q.closed {
q.mu.Unlock()
return fdPushClosed
}
if len(q.holders) == 0 || q.holders[0].id != id || q.live()+len(reqs) > q.max {
q.mu.Unlock()
return fdPushFull
}
signal := q.appendLocked(reqs)
q.dropHolderLocked(id)
wakeSingles := q.live()+q.reserved < q.max
head := q.headWakeLocked()
q.mu.Unlock()
if signal {
q.signalWake()
}
if wakeSingles {
q.signalRoom()
}
signalHolder(head)
return fdPushOK
}
// appendLocked appends an admitted run and reports whether the parked writer
// must be woken. Caller holds mu and has checked the bound.
func (q *fdQueue) appendLocked(reqs []fdReq) (signal bool) {
q.reserve(len(reqs))
q.buf = append(q.buf, reqs...)
signal = q.parked && q.live() >= q.wakeAt
if signal {
q.parked = false
}
return signal
}
// signalWake sends the writer wake claimed under mu. Outside the lock: see push.
func (q *fdQueue) signalWake() {
select {
case q.wake <- struct{}{}:
default:
}
}
// pushFront returns commands the WRITER already took but could not write this
// round (the MaxBatchBytes cap trips mid-wave) to the head of the queue, so they
// go out first and FIFO order is exact. Writer-only: there is one writer, it
// holds no other lock here, and the commands were already admitted, so this is
// not a new enqueue and is deliberately not bounded by max — total outstanding
// work is unchanged.
func (q *fdQueue) pushFront(reqs []fdReq) {
// Unreachable on a nil queue: takeInto yields nothing there, so the writer
// never holds a tail to give back.
if q == nil || len(reqs) == 0 {
return
}
q.mu.Lock()
if q.head >= len(reqs) {
// The freed slots before head take them in place.
q.head -= len(reqs)
copy(q.buf[q.head:], reqs)
} else {
q.buf = append(append(make([]fdReq, 0, len(reqs)+q.live()), reqs...), q.buf[q.head:]...)
q.head = 0
}
q.mu.Unlock()
}
// takeInto moves up to max queued commands onto dst and returns the extended
// slice: one lock and one bulk copy for the whole wave, proportional to the
// wave, not to what stays queued.
//
// The taken slots are cleared, because the backing array outlives them and a
// stale fdReq pins a Cmder, a context and an apBatch. That is 104 bytes per slot
// of memclr, against the per-command lock and copy the channel charged.
// headRun counts the leading queued requests for which match is true. With
// takeInto it lets the engine goroutine take the queued tail of a pipeline:
// only that goroutine removes from the head, so the run cannot change in
// between.
func (q *fdQueue) headRun(match func(fdReq) bool) int {
if q == nil {
return 0
}
q.mu.Lock()
n := 0
for q.head+n < len(q.buf) && match(q.buf[q.head+n]) {
n++
}
q.mu.Unlock()
return n
}
func (q *fdQueue) takeInto(dst []fdReq, max int) []fdReq {
if q == nil || max <= 0 {
return dst
}
q.mu.Lock()
n := q.live()
if n == 0 {
q.mu.Unlock()
return dst
}
if n > max {
n = max
}
dst = append(dst, q.buf[q.head:q.head+n]...)
clear(q.buf[q.head : q.head+n])
q.head += n
switch {
case q.head == len(q.buf):
q.resetEmpty()
case q.head >= q.live():
q.compact()
}
q.parked = false // we are awake; stop submitters from signalling
head := q.headWakeLocked()
q.mu.Unlock()
// Release anyone blocked on a full queue. Non-blocking on cap-1 channels, so
// this is a cheap no-op once a signal is already pending.
q.signalRoomAndHead(head)
return dst
}
// park registers the writer's intent to sleep until the queue holds at least
// minDepth commands. It returns false when the queue ALREADY has that many, in
// which case the writer must take instead of waiting — checking under the same
// lock that a submitter needs to signal is what makes the wake lossless.
//
// minDepth > 1 is only safe when the caller ALSO waits on a timer: submitters
// stay silent below the threshold, so a wave that never reaches minDepth must be
// released by something other than a push.
func (q *fdQueue) park(minDepth int) bool {
if q == nil {
return true // "parked" on a nil wakeCh: that arm never fires
}
if minDepth < 1 {
minDepth = 1
}
q.mu.Lock()
if q.closed || q.live() >= minDepth {
q.parked = false
q.mu.Unlock()
return false
}
q.parked = true
q.wakeAt = minDepth
q.mu.Unlock()
return true
}
// unpark withdraws a standing park request. The writer calls it after any wait
// that did NOT end in a push signal (timer, idle, max-hold, close), so a later
// arrival does not send a wake nobody is listening for.
func (q *fdQueue) unpark() {
if q == nil {
return
}
q.mu.Lock()
q.parked = false
q.mu.Unlock()
}
func (q *fdQueue) depth() int {
if q == nil {
return 0
}
q.mu.Lock()
n := q.live()
q.mu.Unlock()
return n
}
// drainAll removes and returns everything queued. Used by the shutdown and
// fail-backlog paths, which replace the channel's `for { select { case r := <-ch:
// default: return } }` drains.
func (q *fdQueue) drainAll(dst []fdReq) []fdReq {
if q == nil {
return dst
}
q.mu.Lock()
dst = append(dst, q.buf[q.head:]...)
clear(q.buf)
q.resetEmpty()
q.parked = false
head := q.headWakeLocked()
q.mu.Unlock()
q.signalRoomAndHead(head)
return dst
}
// closeQueue rejects further pushes. The engine's submitMu still orders this
// against in-flight submits exactly as it ordered the old channel drain.
func (q *fdQueue) closeQueue() {
if q == nil {
return
}
q.mu.Lock()
q.closed = true
// Every holder, not just the head: a holder behind the head is otherwise
// woken only by its ctx, and Close must release it.
wakes := make([]chan struct{}, len(q.holders))
for i, h := range q.holders {
wakes[i] = h.wake
}
q.mu.Unlock()
// Release submitters blocked on a full queue, singles and holders alike, so
// they observe the closed state.
q.signalRoom()
for _, ch := range wakes {
signalHolder(ch)
}
}