Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 16 additions & 9 deletions src/facade/dragonfly_connection.cc
Original file line number Diff line number Diff line change
Expand Up @@ -1796,17 +1796,24 @@ void Connection::SquashPipeline() {
return;
}

// Send the deferred replies in linked-list order, then release the commands. SendReply() may
// preempt, allowing the producer to append new commands, so re-read the next pointer after it
// and advance the list incrementally to keep it consistent at any preemption point.
// Send all replies under a ReplyScope before releasing the commands.
// This allows the reply builder to flush without copies
{
SinkReplyBuilder::ReplyScope scope(reply_builder_.get());
auto* cmd = parsed_head_;
for (unsigned i = 0; i < squashed && cmd; i++, cmd = cmd->next) {
DCHECK(cmd->CanReply());
// TODO: Coroutine replies don't preserve lifetimes after running, so pause the scope
std::optional<SinkReplyBuilder::ScopePause> pause;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Instead of ScopePause you can add ReplyScope::Pause() / UnPause().

if (cmd->IsSuspendedReply())
pause.emplace(reply_builder_.get());
cmd->SendReply();
conn_stats.pipelined_wait_latency += CycleClock::ToUsec(start - cmd->parsed_cycle);
}
}

for (unsigned i = 0; i < squashed && parsed_head_; ++i) {
auto* current = parsed_head_;

DCHECK(current->CanReply());
current->SendReply();

conn_stats.pipelined_wait_latency += CycleClock::ToUsec(start - current->parsed_cycle);

auto* next = current->next;
ReleasePipelinedCommand(current);
AdvanceParsedHead(next);
Expand Down
4 changes: 2 additions & 2 deletions src/facade/parsed_command.cc
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,8 @@ bool ParsedCommand::CanReply() const {
}

void ParsedCommand::SendReply() {
auto payload_handler = [this](payload::Payload& pl) {
CapturingReplyBuilder::Apply(std::move(pl), rb_);
auto payload_handler = [this](const payload::Payload& pl) {
CapturingReplyBuilder::Apply(pl, rb_);
};
auto task_handler = [](SuspendedCommand& task) {
DCHECK(task.coro);
Expand Down
5 changes: 5 additions & 0 deletions src/facade/parsed_command.h
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,11 @@ class ParsedCommand : public cmn::BackedArguments {
return std::get<SuspendedCommand>(reply_).blocker;
}

// Whether this command will resume a coroutine on SendReply().
bool IsSuspendedReply() const {
return std::holds_alternative<SuspendedCommand>(reply_);
}

// Requires CanReply(). Either sends stored reply or wakes up coroutine to send reply
void SendReply();

Expand Down
10 changes: 5 additions & 5 deletions src/facade/reply_builder.cc
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
#include "absl/types/span.h"
#include "base/cycle_clock.h"
#include "base/logging.h"
#include "common/borrowed_string.h"
#include "facade/error.h"
#include "util/fibers/proactor_base.h"

Expand Down Expand Up @@ -344,7 +345,7 @@ void RedisReplyBuilderBase::SendBulkString(std::string_view str) {
WritePieces(kCRLF);
}

void RedisReplyBuilderBase::SendBulkStringBorrowed(cmn::BorrowedString bs) {
void RedisReplyBuilderBase::SendBulkStringBorrowed(const cmn::BorrowedString& bs) {
ReplyScope scope(this);
tl_facade_stats->reply_stats.borrowed_string_sent_cnt++;

Expand All @@ -359,11 +360,10 @@ void RedisReplyBuilderBase::SendBulkStringBorrowed(cmn::BorrowedString bs) {
WriteRef(bs.view());
}
WritePieces(kCRLF);
}

// Drain all iovecs (including any WriteRef into bs.view()) before this
// function returns. ~BorrowedString releases the pin afterward — by then
// the source bytes are already in the kernel.
Flush();
Comment on lines -363 to -366

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This was totally misleading and tried to make a normal use-case look like the exception.

First, all Send functions assume that the lifetime of the passed reference (or reference object like string_view) does not extend beyond the function. This is why there is a ReplyScope at the top of the function - and when it drops it calls FinishScope that decides on it's own whether to flush or to copy based on it's allowed copy size (all borrowed refs exceeed it, so it would just flush)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I haven't had a deep though when reviewing the PRs, but now we temporarily end up with two functions (const lvalue, rvalue). I think there would have been more elegant solutions

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I do not understand. ReplyScope is not aware of BorrowedString semantics.

Suppose BorrowedString is passed, reply scope says references can be kept, but then BorrowedString goes out of scope and pin is raised.

when ReplyScope finishes, it will reference possible invalid string.
Maybe I broke ReplyScope contract but why removing Flush is valid?

FYI, I assumed that flushing a BorrowedString does not significantly impact performance compared to sending strings themselves.

@dranikpg dranikpg Jun 22, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Because you are in full control of the lifetime. I've added a comment at the top that the reply builder accepts only reference types and assumes each of them goes out if scope immediately - it never says "references can be kept" if you never tell it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

If you want to break that assumption, use ReplyScopes

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Maybe I broke ReplyScope contract but why removing Flush is valid?

Because there is a ReplyScope inside the function and when ut goes out of scope it will either flush or copy the data

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

But if there is another replyscope above this function? The scope will persist, right?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think I can only exend the scoping, I can not disable it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Maybe replacing ReplyScope in this function with ScopePause - would work.

void RedisReplyBuilderBase::SendBulkStringBorrowed(cmn::BorrowedString&& bs) {

@augmentcode augmentcode Bot Jun 18, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

RedisReplyBuilderBase::SendBulkStringBorrowed(cmn::BorrowedString&&) forwards to the const cmn::BorrowedString& overload, which can enqueue a WriteRef(bs.view()) that outlives bs when an outer SinkReplyBuilder::ReplyScope is active (nested ReplyScope won’t call FinishScope() when prev==true). This can lead to use-after-free / early pin release if callers pass temporaries or otherwise short-lived BorrowedStrings under a ReplyScope.

Severity: high

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

SendBulkStringBorrowed(static_cast<const cmn::BorrowedString&>(bs));
}

void RedisReplyBuilderBase::SendLong(long val) {
Expand Down
32 changes: 23 additions & 9 deletions src/facade/reply_builder.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,19 @@
#include "facade/facade_types.h"
#include "io/io.h"

namespace cmn {
class BorrowedString;
} // namespace cmn

namespace facade {

enum class RespVersion { kResp2, kResp3 };

// Base class for all reply builders. Offer a simple high level interface for controlling output
// modes and sending basic response types.
// By default, pointer validity for reference types (string_view, const BorrowedString&) is only
// assumed for the Send...() function call. Use ReplyScope if the lifetime is longer to avoid copies
// and enable zero-copy vectorized IO.
class SinkReplyBuilder {
struct GuardBase {
bool prev;
Expand Down Expand Up @@ -59,6 +66,17 @@ class SinkReplyBuilder {
~ReplyScope();
};

// Temporarily pauses an active ReplyScope. While paused, Send calls copy data as usual
// (no zero-copy refs). Restores the previous scoped_ state on destruction.
struct ScopePause : GuardBase {
explicit ScopePause(SinkReplyBuilder* rb) : GuardBase{std::exchange(rb->scoped_, false), rb} {
}

~ScopePause() {
rb->scoped_ = prev;
}
};

// Aggregator reduces the number of raw send calls by copying data in an intermediate buffer.
// Prefer ReplyScope if possible to additionally reduce the number of copies.
struct ReplyAggregator : GuardBase {
Expand Down Expand Up @@ -197,15 +215,11 @@ class RedisReplyBuilderBase : public SinkReplyBuilder {
void SendSimpleString(std::string_view str) override;
virtual void SendBulkString(std::string_view str); // RESP: Blob String

// Like SendBulkString, but takes ownership of a move-only cmn::BorrowedString
// whose bytes are borrowed from a stable in-memory source (e.g. a CompactObj
// LargeString under the read-only invariant). The default impl handles both
// raw (iovec ref) and packed (chunked decode into scratch) encodings and
// Flushes before returning, so ~BorrowedString releases the pin only after
// the source bytes are in the kernel. CapturingReplyBuilder overrides this
// to move `bs` into the captured payload, extending the pin's lifetime
// through replay.
virtual void SendBulkStringBorrowed(cmn::BorrowedString bs);
void SendBulkStringBorrowed(const cmn::BorrowedString& bs);

// The interface exposes only the rvalue function to allow squashing to "steal" the value,
// the real builder implmenetation forwards it to the constref version
Comment thread
romange marked this conversation as resolved.
Outdated
virtual void SendBulkStringBorrowed(cmn::BorrowedString&& bs);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@dranikpg why do we need in this case virtual void SendBulkStringBorrowed(cmn::BorrowedString&& bs); with temporary value semantics?

do you agree that this function can not run under any scope?


void SendLong(long val) override;
virtual void SendDouble(double val); // RESP: Number
Expand Down
12 changes: 8 additions & 4 deletions src/facade/reply_capture.cc
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ void CapturingReplyBuilder::SendBulkString(std::string_view str) {

// Capture the borrow into the payload, extending the pin's lifetime until
// replay moves it into the real sink where it is parked across the writev.
void CapturingReplyBuilder::SendBulkStringBorrowed(cmn::BorrowedString bs) {
void CapturingReplyBuilder::SendBulkStringBorrowed(cmn::BorrowedString&& bs) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

string_family.cc is the only user of this function. I think this signature should be removed
and the code below should be adjusted to use const ref

void Send(StringResult&& res) const {
    if (holds_alternative<std::string>(res))
      return Send(get<std::string>(res));
    if (holds_alternative<cmn::BorrowedString>(res)) {
      // Move the BorrowedString into SendBulkStringBorrowed; the reply builder takes ownership
      // of the borrow (and associated pin) and parks the pin until all the writes complete.
      rb->SendBulkStringBorrowed(std::move(get<cmn::BorrowedString>(res)));
      return;
    }
    auto fut = get<TieredStorage::TResult<std::string>>(std::move(res));
    io::Result<std::string> iores = fut.Get();
    if (iores.has_value())
      Send(*iores);
    else
      Send(iores.error().message());
  }

SKIP_LESS(ReplyMode::FULL);
Capture(std::move(bs));
}
Expand Down Expand Up @@ -130,8 +130,8 @@ struct CaptureVisitor {
static_cast<RedisReplyBuilder*>(rb)->SendBulkString(bs);
}

void operator()(cmn::BorrowedString bs) {
static_cast<RedisReplyBuilder*>(rb)->SendBulkStringBorrowed(std::move(bs));
void operator()(const cmn::BorrowedString& bs) {
static_cast<RedisReplyBuilder*>(rb)->SendBulkStringBorrowed(bs);
}

void operator()(payload::Null) {
Expand Down Expand Up @@ -166,8 +166,12 @@ void CapturingReplyBuilder::Apply(Payload&& pl, SinkReplyBuilder* rb) {
return;
}

Apply(static_cast<const Payload&>(pl), rb);
}

void CapturingReplyBuilder::Apply(const Payload& pl, SinkReplyBuilder* rb) {

@augmentcode augmentcode Bot Jun 18, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CapturingReplyBuilder::Apply(Payload&&, SinkReplyBuilder*) now forwards to Apply(const Payload&) for non-capturing builders, so it no longer consumes/moves pl even though the signature is rvalue. This makes it easier for callers to accidentally pass a temporary payload under an active ReplyScope, leaving WriteRef iovecs pointing into a payload that is destroyed right after Apply returns.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

CaptureVisitor cv{rb};
visit(cv, std::move(pl));
visit(cv, pl);
}
Comment on lines +169 to 175

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Const replay still consumes 🐞 Bug ≡ Correctness

Reply replay under an outer ReplyScope can queue iovecs that outlive the underlying bytes because
collection replay in CapturingReplyBuilder::Apply(const Payload&) still moves elements out of
CollectionPayload::arr, and RedisReplyBuilderBase::SendBulkStringBorrowed(BorrowedString&&) no
longer takes ownership or extends the pin lifetime (it forwards as const&). As a result,
moved-from/temporary std::string buffers or BorrowedString pins may be destroyed/unpinned before
Connection::SquashPipeline’s outer scope finally flushes, leaving iovecs pointing at
freed/unpinned memory.
Agent Prompt
## Issue description
Fix reply building/replay so that any bytes referenced by queued iovecs remain valid until the deferred flush completes when an outer `ReplyScope` batches multiple replies. Currently, `CapturingReplyBuilder::Apply(const Payload&)` unintentionally consumes collection elements by moving out of `CollectionPayload::arr`, and `RedisReplyBuilderBase::SendBulkStringBorrowed(cmn::BorrowedString&&)` does not actually take ownership/extend pin lifetime, allowing `std::string` buffers or `BorrowedString` pins to be destroyed/unpinned before the outer scope flush sends the queued iovecs.

## Issue Context
`Connection::SquashPipeline` places multiple `cmd->SendReply()` calls under a single outer `ReplyScope`, so inner `Send...()` calls enqueue `WriteRef(...)` without flushing until that outer scope ends. In this environment, replaying a captured collection payload currently does `visit(*this, std::move(pl))` for each element, which can free underlying storage/pins early, and the `BorrowedString&&` overload now forwards as `const&` (no move, no parking), contradicting call-site expectations (see `string_family.cc` comments) and making it easy for temporaries/rvalues to unpin before the deferred flush.

## Fix Focus Areas
- src/facade/reply_capture.cc[145-175]
- src/facade/reply_builder.cc[348-367]
- src/facade/reply_builder.h[215-220]
- src/server/string_family.cc[708-715]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


void CapturingReplyBuilder::SetReplyMode(ReplyMode mode) {
Expand Down
7 changes: 6 additions & 1 deletion src/facade/reply_capture.h
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ class CapturingReplyBuilder : public RedisReplyBuilder {
void SendDouble(double val) override;
void SendSimpleString(std::string_view str) override;
void SendBulkString(std::string_view str) override;
void SendBulkStringBorrowed(cmn::BorrowedString bs) override;

void SendBulkStringBorrowed(cmn::BorrowedString&& bs) override;

void StartCollection(unsigned len, CollectionType type) override;
void SendNullArray() override;
Expand All @@ -56,6 +57,10 @@ class CapturingReplyBuilder : public RedisReplyBuilder {
// Send payload to builder.
static void Apply(Payload&& pl, SinkReplyBuilder* builder);

@romange romange Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

There are too many semantics now. Do we still need "r-value" interface?


// Send payload to builder without consuming it. String refs into the payload remain valid,
// making this safe to use under a ReplyScope when the payload outlives the scope.
static void Apply(const Payload& pl, SinkReplyBuilder* builder);

// If an error is stored inside payload, get a reference to it.
static std::optional<ErrorRef> TryExtractError(const Payload& pl);

Expand Down
31 changes: 31 additions & 0 deletions tests/dragonfly/connection_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -732,6 +732,37 @@ async def measure(aw):
assert await measure(async_client.ft("i1").search("*")) <= 2


@dfly_args({"proactor_threads": "1", "pipeline_squash": 1})
async def test_squashed_reply_count(async_client: aioredis.Redis):
"""Akin to test_reply_count but with pipelines"""

await async_client.ping()

# Create some keys to read
await async_client.set("long-str", "a" * 256)
await async_client.lpush("long-list", *(f"{i}" for i in range(100)))

pre = int((await async_client.info("stats"))["total_writes_processed"])
before = int((await async_client.info("stats"))["total_writes_processed"])
info_diff = before - pre

# Send a large pipeline of simple commands that will be squashed together.
p = async_client.pipeline(transaction=False)
for _ in range(10):
p.set("some-keys", "some-values")
for _ in range(50):
p.get("long-str")
for _ in range(50):
p.lrange("long-list", 0, -1)
await p.execute()

after = int((await async_client.info("stats"))["total_writes_processed"])
writes = after - before

# 90% of the commands don't cause writes. The replies should have been squashed
assert writes - info_diff < 11


@dfly_args({"enable_resp_io_loop_v2": "true"})
async def test_v2_conditional_flush_no_stall(df_server: DflyInstance):
"""Verify that V2 conditional flushing never stalls client replies.
Expand Down
Loading