Skip to content

Implement CLIENT REPLY ON|OFF|SKIP (#1626)#1801

Open
crprashant wants to merge 4 commits into
microsoft:devfrom
crprashant:feature/issue-1626-client-reply
Open

Implement CLIENT REPLY ON|OFF|SKIP (#1626)#1801
crprashant wants to merge 4 commits into
microsoft:devfrom
crprashant:feature/issue-1626-client-reply

Conversation

@crprashant
Copy link
Copy Markdown

@crprashant crprashant commented May 14, 2026

Fixes #1626

Summary

Implements CLIENT REPLY ON|OFF|SKIP matching Redis semantics:

  • ON (default): replies sent normally.
  • OFF: all replies suppressed until next CLIENT REPLY ON.
  • SKIP: one-shot — suppresses the reply of the next non-CLIENT REPLY command (including unknown / parse-error commands), then auto-returns to ON.

Per Redis behavior, CLIENT REPLY OFF and CLIENT REPLY SKIP themselves return no reply; CLIENT REPLY ON returns +OK.

Implementation

Per-session state lives on RespServerSession:

  • clientReplyMode (On/Off/Skip)
  • suppressCurrentReply (per-iteration flag)
  • cmdReplyFloor (snapshot of dcurr at the start of the current command — the rewind floor under suppression so prior queued replies in the same batch are preserved)

In ProcessMessages, before ParseCommand, we snapshot modeAtStart and cmdReplyFloor = dcurr, then set suppressCurrentReply = (modeAtStart != On). This ensures parse-time errors (e.g. malformed / unknown command) are also gated.

SendAndReset() (suppression path):

  1. If the suppressed write made progress (dcurr > cmdReplyFloor): rewind to cmdReplyFloor — keeps [head, cmdReplyFloor) intact.
  2. Else if prior replies are queued (cmdReplyFloor > head): flush [head, cmdReplyFloor), rotate buffer, set cmdReplyFloor = new head. Suppression continues in the fresh buffer.
  3. Else (no prior bytes, no progress): throw — same fatal "buffer too small" condition as the non-suppressed path. Prevents the infinite-loop scenario where a suppressed reply is larger than the entire response buffer.

SendAndReset(IMemoryOwner<byte>, int) (suppression path): drop the payload outright (do not flush the buffer, which may contain this command's earlier bytes between cmdReplyFloor and dcurr).

End-of-command: if still flagged, dcurr = cmdReplyFloor rewinds the accumulated reply. CLIENT REPLY ON clears the flag inside its own handler so its +OK survives. After dispatch, if mode was Skip and the command wasn't CLIENT REPLY, mode auto-resets to On — this fires for unknown / INVALID commands too, matching Redis.

Files

File Change
libs/server/Resp/Parser/RespCommand.cs CLIENT_REPLY enum + parser case + AofIndependentCommands entry
libs/server/Resp/ClientCommands.cs ClientReplyMode enum + NetworkCLIENTREPLY handler
libs/server/Resp/RespServerSession.cs Per-session state + floor, snapshot/rewind hook in ProcessMessages, suppression-aware SendAndReset overloads
libs/server/Resp/CmdStrings.cs REPLY / SKIP u8 constants
libs/resources/RespCommandsInfo.json, RespCommandsDocs.json CLIENT|REPLY metadata
playground/CommandInfoUpdater/SupportedCommand.cs Subcommand registration
test/Garnet.test/RespCommandTests.cs Added CLIENT_REPLY to AofIndependent list
test/Garnet.test/ClientReplyTests.cs (new) 12 raw-TCP NUnit tests

Testing

  • Build: 0 warnings, 0 errors.
  • New ClientReplyTests: 12/12 pass on both net8.0 and net10.0. Coverage:
    • ClientReplyOnReturnsOKON returns +OK.
    • ClientReplyOffSuppressesResponses — OFF gates PING/SET/GET; ON resumes.
    • ClientReplySkipSuppressesOnlyNextCommand — SKIP is one-shot.
    • ClientReplyWrongArityReturnsError — wrong arity returns -ERR… (uses CRLF-aware ReadSimpleReply).
    • ClientReplyInvalidModeReturnsSyntaxError — unknown mode returns -ERR syntax error.
    • ClientReplyCaseInsensitive — lower/mixed case modes accepted.
    • ClientReplyOffPersistsAcrossManyCommands — 50 SETs under OFF, all execute, no reply bytes; verified out-of-band via a second connection.
    • ClientReplyOffErrorAlsoSuppressed — error replies (parse + syntax) are suppressed under OFF.
    • ClientReplySkipDoesNotStack — two consecutive SKIPs only suppress one following command.
    • ClientReplySkipBurnedByUnknownCommand — SKIP followed by unknown command suppresses the error AND burns the SKIP (next command replies normally).
    • ClientReplyPipelinedSkipBetweenRepliesPING / SKIP / PING / PING pipelined in a single TCP write yields exactly two +PONG replies. Verifies the prior reply isn't lost by the in-batch suppression rewind.
    • ClientReplyPipelinedOffOnOrderingPING / OFF / PING / ON pipelined yields +PONG +OK in order.
  • RespCommandTests class: 49/49 pass (incl. AofIndependentCommandsTest and command info/docs metadata).
  • Broader ~Client filter: 130/131 — single failure (RespRangeIndexTests.RIConcurrentMultiClientTest) reproduces on a clean tree, pre-existing flake unrelated to this change.

Known limitation

Inside MULTI/EXEC, CLIENT REPLY queues through the existing NetworkSKIP path and takes effect at EXEC replay time. This matches Redis semantics for v1.

Pub/sub interaction is not specifically tested in this PR — CLIENT REPLY is gated at the generic response buffer layer, so it applies uniformly to all reply types, but a targeted pub/sub test could be added in a follow-up.

Adds per-session CLIENT REPLY mode support matching Redis semantics:
- ON (default): replies sent normally
- OFF: all replies suppressed until next ON
- SKIP: one-shot suppression of the next command's reply

Suppression is implemented in ProcessMessages by snapshotting the mode
and dcurr before ParseCommand, then rewinding dcurr at end-of-command
when suppressed. SendAndReset also discards mid-command flushes when
suppressed. CLIENT REPLY ON clears the flag inside its handler so its
own +OK survives. Snapshotting before ParseCommand ensures parse-time
errors are also gated.

Files:
- libs/server/Resp/Parser/RespCommand.cs: CLIENT_REPLY enum, parser
  case, AofIndependentCommands entry
- libs/server/Resp/ClientCommands.cs: ClientReplyMode enum,
  NetworkCLIENTREPLY handler
- libs/server/Resp/RespServerSession.cs: per-session state, snapshot/
  rewind hook, SendAndReset discard gate
- libs/server/Resp/CmdStrings.cs: REPLY/SKIP u8 constants
- libs/resources/RespCommands{Info,Docs}.json: CLIENT|REPLY metadata
- playground/CommandInfoUpdater/SupportedCommand.cs: subcommand entry
- test/Garnet.test/ClientReplyTests.cs: 9 raw-TCP NUnit tests
- test/Garnet.test/RespCommandTests.cs: AofIndependent list update

Tests: 9/9 new ClientReply pass on net8.0 and net10.0;
RespCommandTests 49/49 pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings May 14, 2026 15:45
@crprashant crprashant changed the base branch from main to dev May 14, 2026 15:52
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 adds Redis-compatible support for CLIENT REPLY ON|OFF|SKIP in the RESP server layer, enabling per-connection reply suppression for fire-and-forget pipelines while preserving CLIENT REPLY ON’s +OK response semantics.

Changes:

  • Added new RespCommand.CLIENT_REPLY parsing/metadata entries and command-info updater registration.
  • Implemented per-session reply-mode state in RespServerSession and a NetworkCLIENTREPLY handler for ON/OFF/SKIP.
  • Added NUnit raw-socket tests covering basic OFF/ON/SKIP behaviors and error cases.

Reviewed changes

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

Show a summary per file
File Description
test/Garnet.test/RespCommandTests.cs Marks CLIENT_REPLY as AOF-independent in the test list.
test/Garnet.test/ClientReplyTests.cs Adds new raw TCP tests for CLIENT REPLY semantics.
playground/CommandInfoUpdater/SupportedCommand.cs Registers `CLIENT
libs/server/Resp/RespServerSession.cs Adds per-session reply suppression state and integrates suppression into processing and flushing.
libs/server/Resp/Parser/RespCommand.cs Adds CLIENT_REPLY enum value, AOF-independent classification, and parser mapping for CLIENT REPLY.
libs/server/Resp/CmdStrings.cs Adds REPLY/SKIP token constants used by parsing/handler logic.
libs/server/Resp/ClientCommands.cs Introduces ClientReplyMode and implements NetworkCLIENTREPLY.
libs/resources/RespCommandsInfo.json Adds command-info metadata entry for `CLIENT
libs/resources/RespCommandsDocs.json Adds command docs entry for `CLIENT
Comments suppressed due to low confidence (1)

libs/server/Resp/RespServerSession.cs:1391

  • When suppressCurrentReply is true, SendAndReset() always returns without making progress even if the current write can never fit in the fixed response buffer. Many write sites use while (!TryWrite...) SendAndReset();; for a reply larger than the buffer this becomes an infinite loop under CLIENT REPLY OFF/SKIP. The non-suppression path explicitly throws when no progress is made; suppression should also guarantee forward progress (e.g., by chunk-discarding, or by preserving the same too-large detection/throw behavior while still honoring suppression semantics).
        internal void SendAndReset()
        {
            // CLIENT REPLY OFF/SKIP: discard whatever was written into the current buffer
            // without sending it. We deliberately do NOT rotate to a fresh buffer or call Send —
            // we just rewind dcurr to the head so the suppressed bytes are dropped.
            if (suppressCurrentReply)
            {
                dcurr = networkSender.GetResponseObjectHead();
                return;
            }

            byte* d = networkSender.GetResponseObjectHead();
            if ((int)(dcurr - d) > 0)
            {
                Send(d);
                networkSender.GetResponseObject();
                dcurr = networkSender.GetResponseObjectHead();
                dend = networkSender.GetResponseObjectTail();
            }
            else
            {
                // Reaching here means that we retried SendAndReset without the RespWriteUtils.Write*
                // method making any progress. This should only happen when the message being written is
                // too large to fit in the response buffer.
                GarnetException.Throw("Failed to write to response buffer", LogLevel.Critical);
            }

Comment thread libs/server/Resp/RespServerSession.cs Outdated
Comment on lines 1368 to 1377
// CLIENT REPLY OFF/SKIP: discard whatever was written into the current buffer
// without sending it. We deliberately do NOT rotate to a fresh buffer or call Send —
// we just rewind dcurr to the head so the suppressed bytes are dropped.
if (suppressCurrentReply)
{
dcurr = networkSender.GetResponseObjectHead();
return;
}

byte* d = networkSender.GetResponseObjectHead();
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch — fixed in 3a034b8. Promoted cmdStartPtr from a local to a per-session field cmdReplyFloor so SendAndReset() can use it as the rewind floor:

  • If the suppressed write made progress (dcurr > cmdReplyFloor): rewind to the floor — bytes in [head, cmdReplyFloor) from prior non-suppressed commands stay intact.
  • Else if prior replies are queued (cmdReplyFloor > head): Send([head, cmdReplyFloor)), rotate buffer, set cmdReplyFloor = new head, continue suppressing in the fresh buffer.
  • Else (no progress and nothing prior): throw — same fatal condition as the non-suppressed path. This also addresses the suppressed comment about infinite loops on oversized suppressed writes.

Also fixed SendAndReset(IMemoryOwner<byte>, int) which had the inverse problem — it would Send() the buffer contents mid-write, flushing this command's earlier bytes before the end-of-command rewind could discard them. Under suppression it now just disposes the payload; the prior bytes remain in the buffer for end-of-batch flush.

Added ClientReplyPipelinedSkipBetweenReplies and ClientReplyPipelinedOffOnOrdering to cover the pipeline ordering case.

Comment thread libs/server/Resp/RespServerSession.cs Outdated
// Burn off a one-shot SKIP. CLIENT REPLY commands themselves never burn the skip —
// a SKIP issued while already in Skip mode just re-arms (does not stack), and a
// following SKIP/ON/OFF transitions the mode directly inside the handler.
if (modeAtStart == ClientReplyMode.Skip && cmd != RespCommand.CLIENT_REPLY && cmd != RespCommand.INVALID)
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed — fixed in 3a034b8. Dropped the cmd != INVALID guard; SKIP now burns for any fully-received command except CLIENT_REPLY itself. Partial input is still gated by the !commandReceived early-break above. Added ClientReplySkipBurnedByUnknownCommand to lock in the behavior.

Comment on lines +15 to +18
/// <summary>
/// Tests for the <c>CLIENT REPLY ON|OFF|SKIP</c> subcommand.
/// Uses a raw TCP socket because StackExchange.Redis cannot tolerate reply suppression.
/// </summary>
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fair point — the description was aspirational. In 3a034b8 I added two pipelining tests (ClientReplyPipelinedSkipBetweenReplies and ClientReplyPipelinedOffOnOrdering) that send multiple commands in a single TCP write and assert exact byte-level reply ordering. I removed the SUBSCRIBE/pub-sub claim from the PR description and noted it as a possible follow-up. Suppression is implemented at the generic response-buffer layer so it applies uniformly across reply types, but a targeted pub/sub test wasn't added here.

Comment thread test/Garnet.test/ClientReplyTests.cs Outdated
Comment on lines +92 to +104
/// <summary>Read until the buffer matches the expected string (or read timeout fires).</summary>
public string ReadExpected(string expected)
{
var sb = new StringBuilder();
while (sb.Length < expected.Length)
{
var chunk = TryRead();
if (chunk.Length == 0) break;
sb.Append(chunk);
}
return sb.ToString();
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 3a034b8. ReadExpected now reads exactly N bytes one at a time (no over-read possible), and I added ReadSimpleReply() that reads up to and including the trailing CRLF for proper RESP simple-string / error parsing. The WrongArity test was switched to ReadSimpleReply so it doesn't leave error-message bytes in the socket buffer between assertions (this was caught immediately by the new exact-byte ReadExpected — the previous chunk-reading version happened to over-read by luck).

Prashant Chinnam and others added 3 commits May 14, 2026 18:07
- SendAndReset: rewind to per-command floor instead of buffer head so
  prior non-suppressed replies queued in the same batch are preserved.
  When the suppressed write made no progress but prior replies exist,
  flush [head, cmdReplyFloor) and rotate to a fresh buffer. When neither
  prior bytes nor progress exist, throw — matches the non-suppressed
  fatal path so an oversized suppressed write cannot infinite-loop.
- SendAndReset(IMemoryOwner): drop the payload under suppression instead
  of flushing partial buffer contents that may contain this command's
  earlier (about-to-be-rewound) bytes.
- Burn one-shot SKIP for INVALID/unknown commands too — matches Redis:
  the SKIP target is the next command attempt regardless of dispatch
  outcome. Partial input is still gated by !commandReceived.
- Tests:
  - ReadExpected reads exactly N bytes (no over-read).
  - Add ReadSimpleReply that reads up to and including CRLF for
    proper RESP simple-string / error parsing.
  - WrongArity test now uses ReadSimpleReply so it doesn't leave bytes
    in the socket between assertions.
  - Add ClientReplySkipBurnedByUnknownCommand: SKIP + unknown cmd
    should suppress the error AND burn the skip.
  - Add ClientReplyPipelinedSkipBetweenReplies: PING/SKIP/PING/PING in
    one TCP write must yield exactly two PONGs (verifies prior reply
    is not lost by the suppression rewind).
  - Add ClientReplyPipelinedOffOnOrdering: PING/OFF/PING/ON in one
    write must yield +PONG +OK.

12/12 ClientReplyTests pass on net8.0 and net10.0; 49/49
RespCommandTests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
dotnet format gate requires no final newline per the repo .editorconfig.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The AllCommandsCovered ACL test enforces that every command in
RespCommandsInfo.json has at least one ACL test case. Adds
ClientReplyACLsAsync that runs 'CLIENT REPLY ON' via GarnetClient.
Uses ON specifically because OFF/SKIP suppress the reply and would
block the synchronous string-result helper.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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.

Feature Request: Support CLIENT REPLY OFF/ON/SKIP subcommands

2 participants