Skip to content

feat(autopipeline): ordered full-duplex dispatch - #3964

Open
ndyakov wants to merge 34 commits into
feature/autopipeline-fullduplex-cscfrom
ndyakov/ap-ordered-fullduplex
Open

feat(autopipeline): ordered full-duplex dispatch#3964
ndyakov wants to merge 34 commits into
feature/autopipeline-fullduplex-cscfrom
ndyakov/ap-ordered-fullduplex

Conversation

@ndyakov

@ndyakov ndyakov commented Aug 13, 2026

Copy link
Copy Markdown
Member

Ordered full-duplex AutoPipeline dispatch (single-connection, WAN-optimized)

Adds an opt-in full-duplex dispatch path to the ordered AutoPipeline (async and blocking faces):
one held pipeline-pool connection with a writer + reader goroutine pair
streams the ordered command stream, instead of the half-duplex
one-batch-per-round-trip flusher. On a latency-bound link under many concurrent
goroutines this decouples writes from reads → ~1 RTT latency at one
connection
, pipe-saturated throughput.

Why

Ordered mode is already one in-flight batch (MaxConcurrentBatches=1), but
half-duplex: write a batch → wait a full RTT → read → write the next. That caps
WAN throughput at batch/RTT and makes late arrivals wait ~2 RTT. Full-duplex on
the same one connection removes the wait.

Measured (50ms WAN proxy, w=4096, inflight=5, errors=0)

path ops/s p50 conns
half-duplex ordered ~207k 116 ms (~2 RTT) 1
full-duplex ordered ~389k 52 ms (~1 RTT) 1

~1.9× throughput and half the latency at the same single connection. On loopback
(no RTT to overlap) FD ≈ HD — the win is WAN.

Scope / gating

Honored on the ordered (Unordered:false, MaxConcurrentBatches<=1),
single-shard faces — both async (AsyncAutoPipeline) and blocking
(AutoPipeline)
— of a standalone *Client that has a pipeline pool.
On the blocking face a single caller gains nothing (one command in flight —
nothing to overlap); the benefit appears with many concurrent blocking callers,
whose commands overlap on the shared pipe like the async face. Validate
rejects the contradictory combos (Unordered, MaxConcurrentBatches>1,
user-set NumShards>1). Cluster stays on the half-duplex path (one ordered
connection can't slot-route). Enabled by AutoPipelineOptions.FullDuplex;
tuned via FullDuplexWindow, FullDuplexIdleTimeout, FullDuplexMaxHold
(zero = sane defaults).

Correctness / operational

  • Retries: on a connection failure the unacked ordered tail is re-issued on a
    fresh connection, honoring shouldRetry/MaxRetries/backoff and the per-command
    NoRetry flag (parity with the half-duplex pipeline's cmdsContainNoRetry).
  • Lease/return: idle + max-hold return the held conn through the pool so its
    per-conn hooks (streaming-creds re-auth, maintnotifications) run.
  • RESP3 push frames demuxed inline (invalidation etc. never misread as a reply).
  • Observability: per-command process hooks (redisotel spans/metrics, custom
    ProcessHooks) fire on the FD path with real write→reply timing; DialHook and
    pool stats unchanged.
  • Blocking / connection-hostile commands (BLPOP, WAIT, XREAD BLOCK,
    SUBSCRIBE, MULTI, …) divert to a separate pooled connection — never stall the pipe.
  • Backpressure: the in-flight window bounds outstanding commands; Submit
    blocks when full.

Tests

25+ full-duplex tests, all -race: ordering under 64 goroutines, RESP3 push
demux, conn-kill recovery, lease/return pool-hook cycling, idle/max-hold recycle,
config defaults + Validate, per-command ProcessHook + short-circuit, ctx-cancel
alignment, goroutine-leak/close-mid-flight, NoRetry guard, blocking-cmd divert,
mid-stream Redis error (FIFO stays aligned), Close-while-backpressured,
backpressure bound, blocking-face activation/ordering/concurrency, chunked-carry
suffix recovery, between-sessions Close flush.

Review hardening

The review rounds hardened failure/lifecycle semantics: accepted work is flushed
(never dropped) on every Close path, including between sessions and when Close
races a lease; a Limiter denial or exhausted lease retries fail-fast the
carry and accepted backlog (separate lease/reconnect retry budgets, each counting
only consecutive failures of its own kind); off-pipe retries are bounded by the
window (end-to-end backpressure); writer/reader/retry/encode panics all recover;
the drain is capped by the remaining window; and native error metrics fire on
every terminal path.

Depends on #3959 (getPipelinePool + always-on pipeline pool). Design notes:
AP_ORDERED_FULLDUPLEX_DESIGN.md.


Note

High Risk
Large new concurrent I/O engine with connection recovery, at-least-once tail replay, and hook/ordering caveats; plus default pipeline-pool behavior change affecting all pipelined traffic.

Overview
Introduces ordered full-duplex dispatch for standalone *Client AutoPipeline when FullDuplex is set on an ordered, single-shard config with a pipeline pool: a writer/reader pair on one leased connection streams commands and matches FIFO replies (~1 RTT under WAN concurrency) instead of half-duplex batch flushes. New tuning options (FullDuplexWindow, idle/max-hold timeouts), validation rules, diversions for blocking/HIMPORT/retryable errors, process-hook hosting, and Len() accounting for the FD queue.

Pipeline pool behavior is aligned with that path: clients get a dedicated pipeline pool by default (PipelinePoolSize negative opts out), burst spill to the main pool, larger default pipeline buffers, no inherited MinIdleConns, and pipeline connections skip client-side tracking when CSC is on.

Supporting changes fix tests and docs that assumed pipelines on the main pool (warm pipeline conn before inject failures, HIMPORT mocks with PipelinePoolSize: -1, pool-stats expectations). govulncheck uses Go stable instead of a pinned minor version.

Reviewed by Cursor Bugbot for commit 53af243. Bugbot is set up for automated code reviews on this repo. Configure here.

ndyakov and others added 13 commits August 10, 2026 13:31
The dedicated pipeline connection pool was created only when
PipelineReadBufferSize or PipelineWriteBufferSize was set. Setting just
PipelinePoolSize therefore produced no pipeline pool at all, with no error and
no log line, and every pipelined operation silently kept using the main
connection pool — competing with ordinary commands for the same connections,
which is the opposite of what the option asks for.

The gating was inverted. The pool size is the primary intent ("give pipelines
their own N connections"); the buffer sizes are tuning. And the buffer sizes
already supplied a default for the size (10), so the dependency only ran one
way.

Any of the three options now creates the pool. Enabling it by size inherits the
regular ReadBufferSize/WriteBufferSize, so a caller who only wants to bound
pipeline connections no longer has to know that a buffer size is secretly
required.

Found while measuring client-side caching over a high-latency link: the CSC
background batched reads go through withPipelineConn, which falls back to the
regular pool when none exists, so they were contending with the very reader
misses they exist to prevent.

Tests cover all four option combinations and were verified to fail with the
gate reverted.
…by default

Autopipelining is pipeline-heavy by definition, yet the dedicated pipeline
pool still had to be enabled separately; without it every batch competes
with regular commands for main-pool connections. Two paths now default it in:

Config time: Options.init defaults PipelinePoolSize to DefaultPipelinePoolSize
(new exported const, 10) when AutoPipelineOptions is set. Cluster node clients
do not inherit AutoPipelineOptions (the cluster autopipeliner lives at cluster
level and dispatches into the node clients' pipeline hooks), so clientOptions
applies the same default there. Sentinel and failover node clients inherit
AutoPipelineOptions and are covered by Options.init; Ring does not support
autopipelining. A negative PipelinePoolSize opts out explicitly.

Runtime: the common pattern configures the autopipeliner after NewClient
(AutoPipelineWithOptions on a plain client), where pool creation has already
happened. ensurePipelinePool now builds the pool lazily when an autopipeliner
is created. baseClient.pipelinePool becomes a pointer to an atomic slot
(pool + pool-name ref) so the lazy publication is race-free against
concurrent Pipelined callers on the hot path, and WithTimeout clones share
the slot — two sharers cannot install two different pools (CAS; the loser
closes its candidate). Streaming-credentials and maintnotifications hooks are
wired before publication; otel registration happens for the winner. Conn, Tx
and SentinelClient never allocate the slot and keep using the main pool.

The extraction into buildPipelinePool also surfaced that NewFailoverClient
kept a duplicated creation block with the pre-fix gate (buffer sizes only),
so PipelinePoolSize alone silently created no pipeline pool on failover
clients; the shared helper fixes that, with a regression test. Stale
RingOptions/FailoverOptions doc lines still describing the old gate are
corrected.
…e pool

The options the dedicated pipeline pool is built with are now resolved by
pipelinePoolOptions, a pure function with pinned tests:

Buffers default to DefaultPipelineBufferSize (new exported const, 64 KiB)
rather than inheriting the regular 32 KiB. Pipeline connections move whole
batches per round trip and earn bigger buffers than per-command traffic:
measured on the autopipeline engine, throughput plateaus around 64 KiB,
gains nothing past ~128 KiB, and very large buffers can regress it. An
explicitly larger regular buffer is kept rather than shrunk, and explicit
pipeline buffer sizes always win. The RESP3 minimum clamp applies as on the
main pool.

MinIdleConns is forced to 0 on the pipeline pool. It previously inherited
the main pool's value through the options clone, so a client with
MinIdleConns set pre-dialed that many pipeline connections at pool creation
— a silent doubling of the idle footprint. The pipeline pool is burst
capacity: its connections dial on demand, and the pool size
(PipelinePoolSize or DefaultPipelinePoolSize) is only a cap, so an unused
pipeline pool holds zero connections.
The autopipeline-subjects job builds its suite subject by calling
AutoPipelineWithOptions on the shared rawClient, which now (correctly)
creates the dedicated pipeline pool lazily - so client.Pipelined and
client.Pipeline run on pipeline-pool connections, not the main pool's.
Two specs assumed the old sharing:

should Auth asserted main-pool stats (Hits=2/Misses=1/TotalConns=1) after
two Pipelined calls. It now branches on PoolStats.PipelineStats: when the
dedicated pool exists, the two pipelines account there (first dials,
second reuses) and the main pool holds only the BeforeEach FlushDB dial;
in the default subject mode PipelineStats is nil and the original
assertions run unchanged.

should ClientSetInfo issued CLIENT SETINFO through a pipeline and then read
CLIENT INFO through rawClient - with a pipeline pool those are different
connections, and SETINFO is per-connection state. It now reads CLIENT INFO
through the same pipeline that issued the SETINFOs, which is equivalent on
a single-pool client and correct on a split-pool one.

Verified against redis:8 in all three GOREDIS_TEST_SUBJECT modes
(default, ap-blocking, ap-async): Ran 2 of 1543 Specs, 2 Passed.
…ions

Two autopipeline unit tests assumed batches execute on the main pool's
already-initialized connection; with the dedicated pipeline pool now
created lazily at autopipeliner creation, a batch's first dispatch dials
and initializes a fresh pipeline-pool connection instead. Both tests now
warm that connection with a real two-command batch first (a lone command
takes the solo fast path through Process on the main pool and would not
touch the pipeline pool):

TestAutoPipelineRetriesOnNetworkError counted the pipeline pool's first
dial as if it were a retry redial (3 dials observed, 2 expected). The
warm-up runs before the write failure is armed, so the count again
measures exactly the one redial the retry performs.

TestAutoPipelineHookPostNextErrorPartialBatch installed a hook that
unconditionally injects a post-next error before the pipeline-pool
connection existed. Connection init runs its handshake pipeline through
the client's hook chain (newConn shares hooksMixin), so the hook failed
the pipeline conn's init itself — retries exhausted, the injected error
was stamped on every command, and the test measured init poisoning
rather than the post-next outcome rule it pins. The warm-up initializes
the connection before the hook is installed.
…ateful

CLIENT TRACKING and CLIENT MAINT_NOTIFICATIONS mutate per-connection
state, exactly like CLIENT SETNAME/SETINFO, but live on cmdable: on a
pooled client they land on an arbitrary pool connection that is
immediately returned to the pool, so the flag applies to a connection
the caller cannot address again. They belong on statefulCmdable, next
to ClientSetName — but the move removes the methods from
Client/UniversalClient/Cmdable (and defining them on both cmdable and
statefulCmdable makes every Pipeline selector ambiguous), so it is a
breaking change deferred to v10. Documented now; the move happens in a
follow-up PR. No behavior change.
The ClientTracking*/ClientMaintNotifications move to statefulCmdable ships
as its own PR against master (feature/stateful-conn-commands) rather than
waiting for v10, so the TODO notes here are obsolete. Docs only.
The dedicated pipeline pool is now created unconditionally at
NewClient/NewFailoverClient — like the pubsub pool — instead of behind a
matrix of triggers (pipeline options set, AutoPipelineOptions declared,
or lazily when an autopipeliner was built). It is pure burst capacity:
no pre-dialing (MinIdleConns forced to 0), a small cap
(DefaultPipelinePoolSize) and 64 KiB buffers, so an unused pipeline pool
holds zero connections and costs nothing, while pipelines stop competing
with regular commands for main-pool connections. PipelinePoolSize < 0
opts out and restores the old pipelines-on-the-main-pool behavior.

To keep this from capping heavy concurrent Pipelined callers — who
previously shared the much larger main pool — withPipelineConn spills to
the main pool when the pipeline pool is exhausted (ErrPoolTimeout):
bursts wider than the cap keep their old capacity, spilled pipelines
simply run with regular buffers, and total connections stay bounded by
PoolSize + PipelinePoolSize.

Pipeline-pool connections are excluded from CLIENT TRACKING during init.
Pipelined commands never consult or populate the built-in client-side
cache (only the single-command cached path on main-pool connections
does), so tracking pipeline reads only grew the server's tracking table
and produced invalidation pushes for keys the cache does not hold.

With creation unconditional, the lazy machinery is deleted: the atomic
slot and CAS publication, ensurePipelinePool, the autopipeliner-factory
hooks, the Options.init AutoPipelineOptions defaulting and its cluster
clientOptions mirror. pipelinePool is a plain field set before the
client is visible to any goroutine; WithTimeout clones share it. Net
diff of this commit is negative.

Tests: the creation-rule table becomes always-on plus opt-out; new e2e
coverage for the spill (a pipeline pool of one, held busy by a pipelined
BLPOP, must not queue a second pipeline behind PoolTimeout) and for the
tracking exclusion (CLIENT LIST shows the main-pool connection tracked
and the pipeline-pool connection untracked).
…efault

The HImport mock tests choreograph exact per-connection sequences — a
pool of one, booms armed to fire on the Nth replayed PREPARE, session
counts pinned per connection. The always-on pipeline pool adds a second
connection whose init replays fieldsets (correct production behavior:
HIMPORT pipelines work on pipeline-pool connections precisely because of
that replay), which fired the armed booms during the pipeline conn's
init instead of the reissue under test and shifted the session counts.
Opt every client in the file out with PipelinePoolSize: -1: the tests
pin HIMPORT reissue/session semantics, not pool routing.

Also states the PipelinePoolSize default plainly in the option docs:
DefaultPipelinePoolSize (10) connections (review feedback).
The shard-level pipeline hooks in the cluster hook-order specs asserted
Expect(cmds).To(HaveLen(...)) unconditionally. With the always-on
pipeline pool, a node's first pipeline dials a pipeline-pool connection
whose init runs its handshake pipeline through the node client's hook
chain, so the assertion fired on the handshake and the resulting Ginkgo
panic aborted the whole suite. Guard both hooks with the idiom
ring_test.go already uses for the same situation: defer GinkgoRecover()
and pass connection-initialization pipelines (hello/client) through.
The cluster-level hooks are untouched — node connection init never runs
through the cluster client's chain.
TestNoPipelinePoolStats pinned the stats shape of a client without a
pipeline pool by creating a client without pipeline options — a premise
the always-on pool inverts. Having no pool now requires the explicit
opt-out, so the test sets PipelinePoolSize: -1, which is exactly the
configuration whose stats shape it verifies.

TestPipelineRetriesOnNetworkError is the plain-pipeline twin of the AP
dial-count test adapted earlier: pipelines now run on the dedicated
pipeline pool, so the first Exec dialed a fresh pipeline-pool connection
and the test counted it as a phantom retry redial. Warm the pipeline
connection with one Pipelined round trip before arming the write
failure, so the count again measures exactly the one redial the retry
performs.
…WAN-optimized)

One held pipeline-pool connection with a writer + reader goroutine pair streams
the ordered command stream instead of the half-duplex one-batch-per-round-trip
flusher. On a latency-bound link under many concurrent goroutines this gives
~1 RTT latency at one connection: WAN 50ms w=4096 inflight=5 ~389k ops/s @ 52ms
p50 vs ~207k @ 116ms for the half-duplex ordered path (errors=0).

- Gated by AutoPipelineOptions.FullDuplex; honored only on the async, ordered
  (Unordered:false, MaxConcurrentBatches<=1), single-shard face of a standalone
  *Client with a pipeline pool. Cluster stays half-duplex. Validate rejects the
  contradictory combos; tuning via FullDuplex{Window,IdleTimeout,MaxHold}.
- Connection-failure retries re-issue the unacked ordered tail on a fresh conn,
  honoring shouldRetry/MaxRetries/backoff and the NoRetry flag (parity with the
  half-duplex pipeline's cmdsContainNoRetry).
- Lease/return (idle + max-hold) returns the held conn through the pool so its
  per-conn hooks (streaming-creds re-auth, maintnotifications) run.
- RESP3 push frames demuxed inline (invalidation etc. never misread as a reply).
- Per-command process hooks (redisotel spans/metrics, custom ProcessHooks) fire
  on the FD path; blocking/conn-hostile commands divert to a separate conn.
- Bounded in-flight window applies backpressure (Submit blocks when full).

18 FD tests, all -race. Depends on #3959 (getPipelinePool + always-on pipeline
pool). See AP_ORDERED_FULLDUPLEX_DESIGN.md.
Comment thread autopipeline_fullduplex.go
Comment thread autopipeline_fullduplex_test.go Outdated
Comment thread autopipeline_fullduplex.go Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1910c6ef83

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread autopipeline_fullduplex.go Outdated
Comment thread autopipeline_fullduplex.go
Comment thread autopipeline_fullduplex.go Outdated
Comment thread autopipeline_fullduplex.go Outdated
Comment thread autopipeline_fullduplex.go
Comment thread autopipeline_fullduplex.go Outdated
Comment thread autopipeline_fullduplex.go Outdated
Comment thread autopipeline_fullduplex.go Outdated
Comment thread redis.go
Comment thread autopipeline_fullduplex.go Outdated
…wn races

Fixes four concurrency defects in the ordered full-duplex dispatch engine:

- failReqs called cmd.Err(), which awaits batch.done -- the very channel
  failReqs then closes -- deadlocking the engine goroutine on retry
  exhaustion / Close. Use rawErr() (the same trap hostHook already documents).
- On a connection-error session end the deque was taken (closeRecover)
  concurrently with the reader, so a command the reader had completed but not
  yet advanced could be handed to the retry loop and re-executed (and, on the
  hooked path, double-close its hookDone channel -- a panic). Split into
  hardClose + takeRemaining and take the tail only after the reader has exited.
- A desynced connection was returned to the pool via Put() unless the error
  was classified bad-conn, poisoning the pool for the next caller (e.g.
  errFDReaderGone, a write timeout). Remove on any fdConnErr session end.
- submit()'s send could win the race against the shutdown drain and enqueue a
  request that was never completed, hanging the caller forever. Gate the send
  with an RWMutex that drainQueue takes before draining.

Also closes the readerDone/clean-result race: a reader protocol error during
an idle/recycle/graceful return now removes the conn instead of Put-ing it
back poisoned, and fails any stranded in-flight tail so no caller hangs.

Adds pure unit tests for the failReqs deadlock and the deque ownership
invariant. Existing full-duplex suite (conn-kill, close-while-backpressured,
ctx-cancel, goroutine-leak) passes under -race.
Comment thread autopipeline_fullduplex.go Outdated
Comment thread autopipeline_fullduplex.go
Comment thread autopipeline_fullduplex.go Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cf785396cb

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread autopipeline_fullduplex.go
Comment thread autopipeline_fullduplex.go
Comment thread autopipeline_fullduplex.go Outdated
Comment thread autopipeline_fullduplex.go
Comment thread autopipeline_fullduplex.go Outdated
Clears the three pre-existing staticcheck failures in the ordered full-duplex
file (present before the teardown/recovery fix; that commit added none):

- ST1008: reorder attempt() and session() return values so the error is the
  last argument -- (unacked, result, aerr) instead of (unacked, aerr, result).
  Pure reordering; the two call sites are updated to match. error and fdResult
  are distinct types, so any misorder fails to compile.
- unused: //nolint:unused on fdInflight.peakLen, which is exercised only by the
  full-duplex backpressure tests (the repo lints with run.tests=false, so
  test-only usage is invisible to the unused check).
@chatgpt-codex-connector

Copy link
Copy Markdown

💡 Codex Review

if fd.ap.ctx.Err() != nil {

P2 Badge Drain FD queue after shared pool close

When an FD autopipeliner is created on one client wrapper and another WithTimeout clone closes the shared base pools, baseClient.Close flips the shared closed flag and closes the pool but does not cancel this autopipeliner's ap.ctx. This loop only observes ap.ctx, so fd.pool.Get starts returning ErrClosed as a connection error and the default branch retries forever without draining fd.ch; any requests accepted just before the shared close remain queued with open batches and their callers block instead of receiving ErrClosed. Check ap.isClosed()/ErrClosed here and drain the queue as the half-duplex path does for shared pool closure.


cn, err := fd.pool.Get(bg)

P2 Badge Let FD lease failures drain or spill queued work

When FullDuplex starts while the dedicated pipeline pool is already saturated (for example PipelinePoolSize: 1 with a long Pipeline holding the only pipeline connection, or a second FD autopipeliner on a WithTimeout clone), this Get runs before the engine reads fd.ch and uses context.Background(). A pool timeout is treated as a connection error with an empty carry, so run only backs off and retries without failing the commands already accepted into fd.ch or spilling to the main pool; those callers can wait indefinitely even though normal pipelines have a spill path. Lease failures need to either use the same spill behavior or drain/fail queued work instead of leaving it unobserved.


go-redis/redis.go

Line 1245 in 28dd5d3

if errors.Is(retErr, pool.ErrPoolTimeout) {

P2 Badge Spill before waiting out the pipeline pool

With the pipeline pool now created by default, workloads that have more than PipelinePoolSize concurrent long-running pipelines can sit in pipelinePool.Get until a pipeline connection is returned or the full PoolTimeout expires before this fallback is attempted, even when the main pool has idle capacity. That means the 11th default pipeline can inherit seconds of avoidable head-of-line blocking from the small pipeline pool, which is a latency regression from the previous main-pool path and contradicts the intended “spill instead of queueing” behavior; use a non-blocking/short pipeline-pool acquisition or otherwise fall back immediately when the dedicated pool is at capacity.

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@ndyakov

ndyakov commented Aug 13, 2026

Copy link
Copy Markdown
Member Author

@codex review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces an opt-in ordered full-duplex dispatch path for the async ordered AutoPipeline, streaming commands over a single held pipeline-pool connection using a writer/reader goroutine pair to overlap writes with reply reads (WAN latency optimization). It also updates pipeline pooling behavior to create a dedicated pipeline pool by default (with opt-out), and expands tests to cover the new dispatch and pooling lifecycle behaviors.

Changes:

  • Add a new full-duplex ordered AutoPipeline engine (fdEngine) with backpressure windowing, retry-on-conn-failure tail replay, RESP3 push demux, and hook hosting for per-command ProcessHook.
  • Create and use a dedicated pipeline connection pool by default (opt-out via PipelinePoolSize < 0), including spill-to-main-pool behavior on pipeline-pool timeouts and excluding pipeline conns from CLIENT TRACKING.
  • Add/adjust tests to pin pool creation/option resolution and to validate FD correctness (ordering, recovery, backpressure, hooks, close behavior).

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
autopipeline.go Adds FullDuplex options, validation, and wires FD engine into ordered async submit path.
autopipeline_fullduplex.go New full-duplex dispatch engine implementation (writer/reader, window backpressure, retries, push demux).
autopipeline_fullduplex_test.go New test suite covering FD correctness, recovery, hooks, backpressure, and close behavior.
redis.go Introduces pipelinePoolRef, shared pipeline-pool construction helpers, and updates pool routing/init/tracking/metrics integration.
options.go Expands pipeline-pool documentation and introduces DefaultPipelinePoolSize / DefaultPipelineBufferSize.
sentinel.go Aligns failover client pipeline pool creation/metrics and filters stale pipeline conns after failover.
ring.go Updates RingOptions pipeline-pool creation docs to match new behavior.
pipeline_pool_gate_test.go New tests pinning pipeline-pool creation rules, option resolution, spill behavior, and tracking exclusion.
pipeline_exec_test.go Warms pipeline-pool conn to keep dial-count assertions stable under new routing.
pipeline_buffer_test.go Updates “no pipeline pool” stats test to opt out explicitly.
osscluster_test.go Adjusts hook-based expectations to ignore node pipeline-conn initialization handshake pipelines.
himport_mock_test.go Opts out of pipeline pool where tests require deterministic main-pool per-connection choreography.
commands_test.go Makes pool-stats and CLIENT SETINFO/INFO assertions robust to the dedicated pipeline pool.
autopipeline_test.go Warms pipeline-pool conns where retry/hook tests are sensitive to initial handshake/dial counts.
Suppressed comments (2)

options.go:262

  • Docs say bursts wider than PipelinePoolSize “spill back to the main pool instead of queueing”, but the current spill happens only after the pipeline pool’s PoolTimeout elapses (it will still queue up to that timeout). Consider clarifying that behavior so expectations match reality.
	// footprint: an unused pipeline pool holds zero connections. A burst of
	// concurrent pipelines wider than the cap spills back to the main pool
	// instead of queueing. Its connections use DefaultPipelineBufferSize

redis.go:1206

  • The comment refers to a concurrent “ensurePipelinePool” publication, but there is no ensurePipelinePool symbol in the codebase (and pipelinePool is documented as set before publication). This stale reference makes the concurrency story around pipelinePool unclear.
	// Load the ref once: a lazy ensurePipelinePool may publish concurrently,
	// and every use below must see the same pool.
	ref := c.loadPipelinePool()

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread options.go
Comment thread redis.go
Comment thread sentinel.go
Comment thread autopipeline_fullduplex.go

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 28dd5d39f0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread autopipeline_fullduplex.go Outdated
Comment thread autopipeline_fullduplex.go Outdated
Comment thread autopipeline_fullduplex.go Outdated
… bytes)

Full-duplex review follow-ups (all in autopipeline_fullduplex.go):
- retry read/write timeouts: shouldRetry(aerr, true), matching the cluster
  pipeline retry paths, so a single deadline miss no longer fails the whole
  unacked tail (cursor High); fdReqsNoRetry still excludes non-idempotent cmds.
- recover a panicking user ProcessHook in hostHook (fail cmd + close batch), so
  it can neither crash the process nor leave the caller blocked.
- close the conn on the connection-error path so a reader blocked in WithReader
  unblocks at once instead of stalling up to the read deadline (cursor High).
- honor a disabled ReadTimeout (<0) as no-deadline instead of clamping to 30s.
- log (not propagate) a push-drain error, matching the workers path, so a custom
  push processor error no longer fails an unrelated in-flight command.
- return a clean conn via releaseConnToPool so pending push notifications are
  drained before Put (no delayed handoff).
- acquire under ap.ctx so Close aborts a blocked pool.Get (init and I/O stay on
  Background so accepted commands still complete); backpressured submit honors
  the caller ctx.
- apply a soft MaxBatchBytes cap in the writer drain loop.
Comment thread autopipeline_fullduplex_test.go Outdated
Comment thread autopipeline_fullduplex.go

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4a0e979a66

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread autopipeline_fullduplex.go Outdated
Comment thread autopipeline_fullduplex.go
Comment thread redis.go
Comment thread autopipeline_fullduplex.go Outdated
Comment thread autopipeline_fullduplex.go
Comment thread redis.go
… bytes)

Full-duplex review follow-ups (all in autopipeline_fullduplex.go):
- retry read/write timeouts: shouldRetry(aerr, true), matching the cluster
  pipeline retry paths, so a single deadline miss no longer fails the whole
  unacked tail (cursor High); fdReqsNoRetry still excludes non-idempotent cmds.
- recover a panicking user ProcessHook in hostHook (fail cmd + close batch), so
  it can neither crash the process nor leave the caller blocked.
- close the conn on the connection-error path so a reader blocked in WithReader
  unblocks at once instead of stalling up to the read deadline (cursor High).
- honor a disabled ReadTimeout (<0) as no-deadline instead of clamping to 30s.
- log (not propagate) a push-drain error, matching the workers path, so a custom
  push processor error no longer fails an unrelated in-flight command.
- return a clean conn via releaseConnToPool so pending push notifications are
  drained before Put (no delayed handoff).
- acquire under ap.ctx so Close aborts a blocked pool.Get (init and I/O stay on
  Background so accepted commands still complete); backpressured submit honors
  the caller ctx.
- apply a soft MaxBatchBytes cap in the writer drain loop.
- honor opt.Limiter per session: Allow() before the session pool.Get,
  ReportResult() before release (report-before-release), 1:1 per session
  (the Limiter gates conn acquisition; FD acquires one conn per session).
- on a retryable Redis error (LOADING/READONLY/CLUSTERDOWN/…) or a redirect
  (MOVED/ASK), re-run the command on the client normal path (pipeliner.process)
  off the reader goroutine (tracked by retryWg so Close waits) and settle the
  caller with that result; ordering across the divert is not promised (same as
  the blocking-command divert), NoRetry commands keep their error.
- record the native per-command OTel metric (RecordOperationDuration, write->reply)
  from the reader, mirroring process; skipped for retry-diverted commands (they
  emit via process). fdReq carries its submit ctx + write timestamp.
- divert managed HIMPORT (PREPARE/SET/DISCARD/DISCARDALL) off the FD pipe to the
  normal Process path: the FD writer streams raw commands and never replays the
  registered PREPARE (connection-session state), so an HIMPORT SET on the pipe
  could fail "no such fieldset". Process injects the PREPARE from the registry and
  keeps it current on PREPARE; the half-duplex sharded path injects inline
  (himportInjectedCmds) and is left on the pipeline. FD-scoped: gated on ap.fd.
- wait for per-command hook-host goroutines during Close: a ProcessHook doing work
  after next() returns runs on a host goroutine that is the only code closing that
  command's batch. Track it in a hostWg that run() waits (so Close, which waits
  ap.wg, waits it too), restoring the drain-before-return contract. The hostWg.Add
  is gated behind submitMu+closed exactly like the ch send, so run()'s hostWg.Wait
  never races a live Add (every run() return is preceded by drainQueue).
- stamp the FD hook host as the batch executor (dispGid) so a ProcessHook that
  reads its own command result after next() (cmd.Err()/cmd.String()) gets the
  just-executed view instead of self-deadlocking on the batch only that goroutine
  closes (cursor P1). Mirrors runOutsidePipeline's async dispatch guard.
Comment thread autopipeline_fullduplex.go Outdated
@ndyakov

ndyakov commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

@codex review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated no new comments.

Suppressed comments (11)

autopipeline.go:118

  • This violates the existing AddHook contract, which states that omitting next prevents command execution (redis.go:150-182). Sending anyway turns policy, cache, and mock hooks into unintended server side effects; documenting the incompatibility does not preserve the API contract. The wire write must remain gated by next, or FullDuplex must not activate when hooks are installed.
	// Caveat: because the write is already queued on the shared stream when the hook
	// host starts, a hook that SHORT-CIRCUITS (returns without calling next) does
	// NOT cancel execution — the command still runs on the wire; only the hook's
	// returned error is reflected to the caller. This differs from the half-duplex
	// path, where next() gates the write. A hook that relies on short-circuiting to
	// BLOCK a command (a policy/ACL/kill-switch hook, or a mock/cache that must not
	// touch the server) therefore does NOT prevent the server write under FullDuplex
	// — run such hooks on a plain client or the half-duplex autopipeline. Hooks that

autopipeline_fullduplex.go:356

  • The request is visible to the writer before this goroutine starts. On a fast server the writer/reader can complete and close hookDone before the scheduler runs hostHook, producing a near-zero tracing span that does not bracket write→reply as promised. Add a startup handshake so the observing hook has entered before the command can be written.
		if hookDone != nil {
			fd.hostWg.Add(1)
			go fd.hostHook(ctx, cmd, b, hookDone)

options.go:250

  • These statements contradict both the unconditional creation below and the actual buffer resolution: a positive size overrides the default rather than creating the pool, and unset pipeline buffers use the larger of the regular size and DefaultPipelineBufferSize, not simply the regular sizes. Update this public option documentation to match pipelinePoolOptions.
	// Setting this alone is enough to create the dedicated pipeline pool; the
	// pipeline buffer sizes then default to ReadBufferSize/WriteBufferSize.

redis.go:1253

  • The spill path still has active dedicated-pool limiter accounting, but c.withConn performs another Allow/ReportResult pair through getConn/releaseConn. A limiter can therefore reject an already-admitted spill and receives duplicate results. Acquire, initialize, and release c.connPool directly under the existing limiter lease instead of nesting withConn.
			return c.withConn(ctx, fn)

redis.go:652

  • opt.clone() retains the main pool's MaxActiveConns. For example, MaxActiveConns: 1 makes this nominally 10-connection pipeline pool return ErrPoolExhausted after one connection, and withPipelineConn does not spill because it only handles ErrPoolTimeout. Reset this field so PipelinePoolSize remains the dedicated pool's cap.
	pipelineOpt.MinIdleConns = 0

redis.go:630

  • Cloning also carries the main pool's default PoolTimeout (normally ReadTimeout + 1s, i.e. 6s). Since spill happens only after that timeout, the 11th concurrent pipeline can queue for six seconds before using the main pool, contrary to the documented “spills … instead of queueing” behavior. Give the burst pool a short dedicated timeout and cover the default, not only the test's manually shortened timeout.
func pipelinePoolOptions(opt *Options) *Options {
	pipelineOpt := opt.clone()

autopipeline_fullduplex.go:303

  • The default window eagerly allocates a 65,536-element channel backing store (roughly 4.5 MiB for fdReq) for every FullDuplex autopipeliner, even when no command is submitted. This contradicts the API claim that a generous window costs no memory until in-flight work grows. Decouple queue capacity from the in-flight window or use a lazily growing queue.
		ch:       make(chan fdReq, w),

autopipeline_fullduplex.go:863

  • The window is checked only before draining, after which this loop can append an entire MaxBatchSize. With FullDuplexWindow: 1 and the default batch size, in-flight can jump to 200, so the advertised hard bound is not enforced. Cap each drained batch by the remaining window room.
				for len(batch) < fd.maxBatch {

autopipeline_fullduplex.go:745

  • The PR description claims a RESP3 push-demux test, but TestFullDuplexStaysAlignedUnderConcurrentMutation explicitly says it never sends a push to the FD connection and that a mock injection test is still a follow-up (autopipeline_fullduplex_test.go:113-122). This FIFO-critical branch therefore remains unverified; add a test that injects a push frame immediately before a reply.
					// Drain RESP3 push frames buffered ahead of this reply so a
					// push is never misread as the command's reply (FIFO misalign).
					// A push-drain error is logged and NOT propagated (matching the
					// workers path in flushBatch): a custom push processor returning
					// an error must not fail this unrelated in-flight command or kill
					// the connection. A genuine transport error re-surfaces in the
					// readReply below and is handled as the connection error it is.
					if perr := fd.client.processPendingPushNotificationWithReader(bg, cn, rd); perr != nil {

sentinel.go:132

  • This public documentation still describes the pool as optional and tied to these fields/AutoPipelineOptions, but NewFailoverClient now creates it for every client unless PipelinePoolSize < 0. Document the default creation and explicit opt-out so callers can predict connection capacity.
	// PipelineReadBufferSize, PipelineWriteBufferSize and PipelinePoolSize
	// configure an optional separate connection pool used for pipelining, with
	// its own (typically larger) buffers. See the same-named fields on Options
	// for details. Setting any of the three creates the pool; it also defaults
	// in when AutoPipelineOptions is set.

ring.go:154

  • Each shard client now creates the dedicated pipeline pool by default even when all three fields are zero, so “Setting any … creates” is misleading. State the default creation and PipelinePoolSize < 0 opt-out, consistent with Options.
	// PipelineReadBufferSize, PipelineWriteBufferSize and PipelinePoolSize
	// configure an optional separate connection pool used for pipelining on
	// each shard, with its own (typically larger) buffers. See the same-named
	// fields on Options for details. Setting any of the three creates the pool
	// on each shard client.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f7d9b731b0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread autopipeline_fullduplex.go
Comment thread autopipeline_fullduplex.go
Under a sustained retryable stream (LOADING/READONLY on every reply) each
diverted reply spawned an independent normal-path retry goroutine while the
reader kept advancing the window and admitting more writes — unbounded
goroutines parked in backoff/pool acquisition. retryOnNormalConn now acquires a
window-sized semaphore slot before spawning; a full semaphore blocks the READER,
which stops advancing the deque, fills the window, and blocks the writer and
submitters — end-to-end backpressure, worst case window on-pipe + window
off-pipe. No cycle: retries drain on the main pool, independent of the reader.
@ndyakov

ndyakov commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

@codex review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated no new comments.

Suppressed comments (8)

sentinel.go:132

  • This describes conditional/lazy creation, but NewFailoverClient now creates the pool whenever PipelinePoolSize >= 0, even when all three fields and AutoPipelineOptions are unset. Document the default-on behavior and the negative opt-out so callers can predict connection capacity.
	// for details. Setting any of the three creates the pool; it also defaults
	// in when AutoPipelineOptions is set.

options.go:250

  • This says unset pipeline buffers inherit the regular buffer sizes, but pipelinePoolOptions actually uses the larger of those sizes and DefaultPipelineBufferSize. It also implies this field triggers creation although the pool is now default-on. Align this opening summary with the detailed behavior below.
	// Setting this alone is enough to create the dedicated pipeline pool; the
	// pipeline buffer sizes then default to ReadBufferSize/WriteBufferSize.

redis.go:1253

  • Spilling through withConn performs a second Limiter.Allow/ReportResult pair after this function already acquired and deferred reporting for the limiter. Under saturation this can consume two breaker permits for one pipeline, or let the second Allow reject an operation that was already admitted. Acquire from the main pool without re-entering limiter accounting, while preserving report-before-release ordering.
			return c.withConn(ctx, fn)

redis.go:652

  • The cloned main-pool MaxActiveConns is still applied to the pipeline pool. For example, MaxActiveConns: 1 with the default pipeline size makes a second pipeline fail immediately with ErrPoolExhausted; the spill path only handles ErrPoolTimeout. Reset this field so PipelinePoolSize independently controls the dedicated pool as intended.
	pipelineOpt.MinIdleConns = 0

redis.go:650

  • pipelineOpt also retains the main pool's PoolTimeout (six seconds by default), so the first pipeline beyond this small cap waits that full interval before spilling. This contradicts the dependency's short pipeline-pool timeout and turns bursts into multi-second stalls; apply a dedicated short timeout here.
		pipelineOpt.PoolSize = DefaultPipelinePoolSize

autopipeline_fullduplex.go:859

  • This check does not enforce FullDuplexWindow as the documented hard in-flight limit. Once below the window, the drain below can append an entire MaxBatchSize batch, so FullDuplexWindow: 1 can still put roughly 200 commands in flight; replaying carry also bypasses the limit. Cap each write by the remaining window capacity, including replay chunks.
			for inflight.len() >= fd.window {

autopipeline_fullduplex.go:605

  • attempts remains incremented after the replay succeeds and the new session resumes serving commands; it resets only on idle/max-hold return. Under continuous load, a later unrelated connection loss therefore inherits the earlier retry count and can exhaust MaxRetries immediately. Reset the retry budget once the recovered tail is acknowledged or otherwise establish successful-session progress before applying it to new commands.
					attempts++
					fd.sleepBackoff(attempts)
					carry = unacked[:n]

ring.go:154

  • The shard clients now create their dedicated pipeline pools by default, not only when one of these fields is set. This public option comment should state the default-on behavior and PipelinePoolSize < 0 opt-out.
	// fields on Options for details. Setting any of the three creates the pool
	// on each shard client.

Comment thread autopipeline_fullduplex.go

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d32b20b25e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread redis.go
Comment thread autopipeline_fullduplex.go
Comment thread autopipeline_fullduplex.go
…rity

- run() keeps two budgets, each counting only consecutive failures of its own
  kind: leaseAttempts (fdLeaseErr/fdDenied) and retryAttempts (fdConnErr tail
  replays). leaseAttempts resets whenever a session actually runs and both reset
  on a clean end, so transient lease failures can no longer consume the
  reconnect budget and leave a genuine drop with zero replay attempts.
- The reader's inline completion emits the native error metric
  (classifyCommandError + pool.GetMetricErrorCallback) for non-retryable Redis
  errors, matching the normal command path.
- The writer runs a non-blocking readerDone priority check before its main
  select, so randomized select can no longer write another batch to a
  connection the engine already knows has no reader.
Comment thread autopipeline_fullduplex.go

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d6625b02f3

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread autopipeline_fullduplex.go
Comment thread autopipeline_fullduplex.go
Comment thread autopipeline_fullduplex.go
Comment thread redis.go
Drop the !blocking term from the fdOn gate: FullDuplex is now honored on the
blocking AutoPipeline face too. No new machinery was needed — submit()'s fd
branch already skips setReady on the blocking face (its contract) and
processBlocking waits on the returned batch, the same shape as a half-duplex
enqueue; the dispGid self-deadlock guard and everything downstream are
face-agnostic.

A single blocking caller gains nothing (one command in flight — nothing to
overlap; only held-conn overhead); the benefit appears with many concurrent
blocking callers, whose commands overlap on the shared pipe like the async
face (~1 RTT each instead of batch phase-locking). The FullDuplex GoDoc states
this and notes the blocking face's per-goroutine ordering is unaffected by the
retry divert (each caller waits per command by construction).

Adds TestFullDuplexBlockingFace.
- retryOnNormalConn recovers a panic from process (hooks/encoders are user
  code): the command gets the panic as its error and always completes, so the
  caller and a hooked command's host cannot hang — matching the writer, reader
  and hostHook recovery discipline.
- The writer's drain is capped by the remaining window room
  (min(MaxBatchSize, window - inflight)), so FullDuplexWindow smaller than
  MaxBatchSize can no longer be blown through by a single batch.
- writeCarryChunked observes readerDone between chunks (carry replay and the
  Close flush both pass it): if the reader exits mid-replay the un-written
  remainder is pushed to the deque for recovery instead of being written to a
  reader-less connection.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1eecfcdc37

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread autopipeline_fullduplex.go
…ands

Commands terminated through failReqs (lease failure, retry exhaustion, a
NoRetry tail, Close) never reach the reader's inline completion, so they were
invisible to the native error callback. failReqs now classifies the shared
failure once and emits the callback per command. No duration metric: these
commands have no meaningful write-to-reply span (many were never written).
Comment thread autopipeline_fullduplex.go
Comment thread autopipeline.go

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c824b4d46c

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread autopipeline_fullduplex.go Outdated
Comment thread autopipeline_fullduplex.go
Comment thread redis.go
Comment thread redis.go
…lush

- failQueue emits the native error callback per drained request, mirroring
  failReqs: on fdLeaseErr/fdDenied both halves of the failure (carry and the
  accepted channel backlog) are now visible to extra/redisotel-native.
- Validate rejects FullDuplex with a user-set NumShards > 1 (one held FIFO
  connection is a single stream) instead of silently falling back to
  half-duplex; the cluster wiring's internal contentSharded flag is exempt,
  preserving the documented cluster fallback.
- shutdownFlush re-issues the between-sessions Close backlog in the same
  MaxBatchSize/MaxBatchBytes chunks as normal FD writes (fdBatchEnd) instead of
  one unchunked pipeline that ignored MaxBatchBytes.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 38065ef00e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread autopipeline_fullduplex.go Outdated
Comment thread autopipeline_fullduplex.go Outdated
Comment thread autopipeline_fullduplex.go Outdated
Comment thread autopipeline_fullduplex.go Outdated
…lush

- failQueue emits the native error callback per drained request, mirroring
  failReqs: on fdLeaseErr/fdDenied both halves of the failure (carry and the
  accepted channel backlog) are visible to extra/redisotel-native.
- Validate rejects FullDuplex with a user-set NumShards > 1 (one held FIFO
  connection is a single stream) instead of silently falling back to
  half-duplex; the cluster wiring's internal contentSharded flag is exempt,
  preserving the documented cluster fallback.
- shutdownFlush re-issues the between-sessions Close backlog in the same
  MaxBatchSize/MaxBatchBytes chunks as normal FD writes instead of one
  unchunked pipeline that ignored MaxBatchBytes; it stops (failing the
  remainder) after a transport-class chunk failure instead of re-running the
  retry cycle per chunk against a dead endpoint, and recovers an encoder panic.
- The reconnect retry budget resets when a session completed work (including a
  successful carry replay): a drop after real progress is a fresh failure, not
  a consecutive one.
- The submit queue is capped at min(window, 4096): a buffered channel
  allocates its capacity eagerly (~5 MiB per engine at the default window);
  backpressure is enforced by the in-flight deque, so the window keeps its
  meaning as the in-flight bound.

Also compacts the review-round comments in the touched files (invariants and
contracts preserved; narration removed).
Comment thread autopipeline_fullduplex.go
Recovered panics (reply decode, batch encode) surfaced as plain errors, so
shouldRetry rejected them and the connection-error path permanently failed the
whole unacked tail — mostly commands the panic never touched. Wrap them with an
errFDPanicRecovered sentinel and treat it (and errFDReaderGone) as replayable:
the conn is desynced exactly like a transport error, so the tail replays on a
fresh connection with the NoRetry split and MaxRetries budget unchanged.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b051877. Configure here.

Comment thread autopipeline_fullduplex.go

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b051877c8a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread autopipeline_fullduplex.go
Comment thread redis.go
The reader acquired a retry slot with a bare channel send, so with the
semaphore full it parked where it could no longer observe hardClose or
the client context, and session teardown waited on readerDone for as
long as a slot took to free. The acquire now selects on the semaphore
and the autopipeliner context: on Close the request fails with
ErrClosed instead of parking the reader past teardown. Outside of Close
the wait stays bounded — every slot holder is a retry running through
process(), whose timeouts guarantee the slot frees.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 53af243df3

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +476 to +479
case <-fd.ap.ctx.Done():
req.cmd.SetErr(ErrClosed)
req.complete()
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve accepted retries during Close

When a sustained stream of retryable replies fills retrySem and Close is called while the reader is waiting for another slot, this branch replaces the already-accepted command's Redis result with ErrClosed instead of completing its configured normal-path retry. Because cancellation remains ready, the reader can similarly fail the rest of the in-flight retryable replies during teardown, contradicting Close's accepted-work drain contract; keep these retries tracked through shutdown or otherwise settle them without treating them as post-Close submissions.

Useful? React with 👍 / 👎.

Comment on lines +1303 to +1305
backlog = append(backlog, r)
default:
return fd.writeCarryChunked(bg, cn, inflight, backlog, readerDone)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce the FD window while flushing on Close

When Close wins while replies are slow and the in-flight deque is already at FullDuplexWindow, this writes the entire buffered backlog without waiting for reader progress. Since the channel can contain up to min(window, 4096) additional requests, a small configured window can be exceeded by roughly 2× during shutdown, violating the option's documented hard bound on written-but-unacknowledged commands; apply the same remaining-window backpressure between close-flush chunks.

Useful? React with 👍 / 👎.

// It records the create-time metric, unwraps the init error, and Removes the
// conn on any failure (so the defer, seeing cn=nil, does not double-release); it
// does NOT Put the conn, which suits the held-conn model.
if e := fd.client.initPooledConn(bg, fd.pool, cn); e != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Pass the submit context into FD connection initialization

When CredentialsProviderContext derives credentials from values on the submitted command's context, every newly dialed full-duplex connection is initialized with context.Background() here, so resolveCredentials cannot see those values. Such providers can reject all FD sessions or return fallback credentials for the wrong identity even though the command supplied the correct context; initialize the session using the carry request's context, or reject FullDuplex when context-dependent authentication cannot be preserved.

AGENTS.md reference: AGENTS.md:L154-L160

Useful? React with 👍 / 👎.

Comment on lines +786 to +788
if perr := fd.client.processPendingPushNotificationWithReader(bg, cn, rd); perr != nil {
internal.Logger.Printf(bg, "autopipeline: full-duplex push drain: %v", perr)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop new FD writes after a MOVING notification

When this drain handles a maintenance MOVING push during continuous full-duplex traffic, the handler only marks cn for handoff and the writer never checks that state. The connection is therefore not returned through Put—where the pool hook actually queues the handoff—until idle or the default five-second max-hold expires, so commands continue being written to a node known to be moving and the handoff can miss its deadline; signal the writer to drain and recycle the session as soon as cn.ShouldHandoff() becomes true.

AGENTS.md reference: AGENTS.md:L134-L146

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants