Skip to content

feat: Add ReplyScope to SquashPipeline#7630

Merged
dranikpg merged 4 commits into
dragonflydb:mainfrom
dranikpg:squash-reply-scope
Jul 1, 2026
Merged

feat: Add ReplyScope to SquashPipeline#7630
dranikpg merged 4 commits into
dragonflydb:mainfrom
dranikpg:squash-reply-scope

Conversation

@dranikpg

@dranikpg dranikpg commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Send replies from SquashPipeline under a ReplyScope to avoid copies and use the zero-copy vectorized IO features of the sink reply builder. Deallocate commands only after all replies were sent

Performance comparision against the main branch for GET benchmark. With 10+ connections and long pipelines (50) the difference in throughput becomes 15% and more

image image

@dranikpg
dranikpg force-pushed the squash-reply-scope branch from dea237e to 17b19a0 Compare June 18, 2026 10:15
Comment on lines -363 to -366
// 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();

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.

@dranikpg
dranikpg requested a review from romange June 18, 2026 10:22
@dranikpg dranikpg changed the title feat: squash reply scope feat: Add ReplyScope to SquashPipeline Jun 18, 2026
@dranikpg
dranikpg marked this pull request as ready for review June 18, 2026 14:53
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add ReplyScope batching to SquashPipeline for zero-copy replies
✨ Enhancement 🕐 20-40 Minutes

Grey Divider

Description

• Batch pipelined reply sending under ReplyScope to enable zero-copy sink flushing.
• Defer pipelined command release until after all replies are emitted.
• Adjust payload/borrowed-string APIs to preserve lifetimes without extra moves/copies.
Diagram

graph TD
  A["Connection::SquashPipeline"] --> B["SinkReplyBuilder::ReplyScope"] --> C["ParsedCommand::SendReply"] --> D["CapturingReplyBuilder::Apply"] --> E["RedisReplyBuilderBase"] --> F["Socket writev/flush"]
  C --> G["ScopePause (suspended)"] --> E
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Per-reply flush / no ReplyScope
  • ➕ Simpler lifetime model (each SendReply self-contained)
  • ➕ Lower risk around suspended/coroutine replies
  • ➖ Misses zero-copy vectorized IO gains for long pipelines
  • ➖ More syscalls/copies under high concurrency
2. Make suspended replies lifetime-safe under ReplyScope
  • ➕ Avoids ScopePause fallback; maximizes zero-copy coverage
  • ➕ Cleaner performance story (uniform behavior)
  • ➖ Requires deeper coroutine/lifetime refactoring (pinning/ref-counting payload buffers)
  • ➖ Higher risk of leaks or long-lived pins if not carefully bounded

Recommendation: Current approach is a good tradeoff: it enables ReplyScope zero-copy for the common pipelined case while explicitly pausing scoped mode for suspended/coroutine replies where lifetimes are not preserved. This keeps the change localized to the pipeline and reply-building APIs, with an escape hatch (ScopePause) until suspended replies can be made scope-safe end-to-end.

Files changed (7) +62 / -30

Enhancement (5) +55 / -23
dragonfly_connection.ccWrap SquashPipeline reply emission in ReplyScope and defer releases +16/-9

Wrap SquashPipeline reply emission in ReplyScope and defer releases

• Sends all squashed/pipelined replies under a single SinkReplyBuilder::ReplyScope to enable zero-copy flushing. Adds conditional ScopePause for suspended/coroutine replies, and only releases parsed commands after reply emission completes.

src/facade/dragonfly_connection.cc

parsed_command.hExpose whether SendReply resumes a suspended command +5/-0

Expose whether SendReply resumes a suspended command

• Adds ParsedCommand::IsSuspendedReply() to detect coroutine-backed replies. Used by pipeline code to temporarily pause ReplyScope when lifetimes cannot be guaranteed across coroutine execution.

src/facade/parsed_command.h

reply_builder.hDocument ReplyScope lifetime rules and add ScopePause guard +20/-9

Document ReplyScope lifetime rules and add ScopePause guard

• Documents that reference-type validity is assumed only for the duration of Send calls unless ReplyScope is used. Introduces ScopePause to temporarily disable scoped zero-copy behavior (forcing copies) and restores previous scope state on destruction; updates borrowed-string method signatures accordingly.

src/facade/reply_builder.h

reply_capture.ccEnable non-consuming payload replay and const borrowed-string replay +8/-4

Enable non-consuming payload replay and const borrowed-string replay

• Changes capturing builder borrowed-string send override to accept rvalues, and updates the payload visitor to replay borrowed strings by const reference. Adds CapturingReplyBuilder::Apply(const Payload&) and updates Apply(Payload&&) to delegate without consuming, preserving refs for ReplyScope usage.

src/facade/reply_capture.cc

reply_capture.hAdd const Apply overload and update borrowed-string override signature +6/-1

Add const Apply overload and update borrowed-string override signature

• Updates SendBulkStringBorrowed override to rvalue-only (capturing ownership), and adds Apply(const Payload&) for safe replay without consuming payload storage. Documents that this is intended for ReplyScope scenarios where payload outlives the scope.

src/facade/reply_capture.h

Refactor (2) +7 / -7
parsed_command.ccAvoid consuming payload during reply replay +2/-2

Avoid consuming payload during reply replay

• Changes the payload handler to pass payload as const reference to CapturingReplyBuilder::Apply instead of moving it. This aligns reply sending with ReplyScope assumptions by keeping payload storage intact during replay.

src/facade/parsed_command.cc

reply_builder.ccSplit borrowed-string send into const-ref + rvalue overload +5/-5

Split borrowed-string send into const-ref + rvalue overload

• Updates SendBulkStringBorrowed to take a const BorrowedString& and adds an rvalue overload that forwards to the const-ref version. This supports scoped, zero-copy reference sending without forcing moves, while keeping API compatibility for move-callers.

src/facade/reply_builder.cc

@augmentcode

augmentcode Bot commented Jun 18, 2026

Copy link
Copy Markdown
🤖 Augment PR Summary

Summary: This PR optimizes pipelined reply emission by using a single ReplyScope during pipeline squashing to reduce copies and better leverage vectorized IO.

Changes:

  • Wrap Connection::SquashPipeline() reply sending in SinkReplyBuilder::ReplyScope and release pipelined commands only after replies are emitted.
  • Add ScopePause to temporarily disable scoped zero-copy behavior for suspended/coroutine replies.
  • Adjust deferred reply replay to apply payloads without consuming them, keeping string/borrowed refs valid across the scope.
  • Refine SendBulkStringBorrowed and capture/replay handling so borrowed strings can be replayed by reference under a scope.

🤖 Was this summary useful? React with 👍 or 👎

@augmentcode augmentcode 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.

Review completed. No suggestions at this time.

Comment augment review to trigger a new review at any time.

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 18, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Const replay still consumes 🐞 Bug ≡ Correctness
Description
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.
Code

src/facade/reply_capture.cc[R169-175]

+  Apply(static_cast<const Payload&>(pl), rb);
+}
+
+void CapturingReplyBuilder::Apply(const Payload& pl, SinkReplyBuilder* rb) {
 CaptureVisitor cv{rb};
-  visit(cv, std::move(pl));
+  visit(cv, pl);
}
Evidence
Connection::SquashPipeline wraps multiple cmd->SendReply() calls in a single outer ReplyScope,
so inner SendBulkString/SendBulkStringBorrowed calls enqueue WriteRef(...) and do not flush
until the outermost scope ends. During replay of a collection payload, CaptureVisitor moves each
element out of cp->arr (via visit(*this, std::move(pl))), which can release the referenced
std::string storage or drop BorrowedString pins when the temporary/moved-from objects are
destroyed at the end of the statement, while the builder is still holding iovecs referencing those
bytes (e.g., bs.view()); additionally, the rvalue SendBulkStringBorrowed(BorrowedString&&)
overload no longer moves/parks ownership and merely forwards to the const& overload, despite call
sites (notably in string_family.cc comments) expecting ownership transfer/pin parking, making the
delayed-flush scenario particularly unsafe.

src/facade/dragonfly_connection.cc[1766-1807]
src/facade/parsed_command.cc[117-127]
src/facade/reply_capture.cc[145-175]
src/facade/reply_builder.cc[81-85]
src/facade/reply_builder.cc[337-346]
src/facade/reply_builder.cc[348-367]
src/server/string_family.cc[708-715]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## 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


Grey Divider

Qodo Logo

Comment on lines +169 to 175
Apply(static_cast<const Payload&>(pl), rb);
}

void CapturingReplyBuilder::Apply(const Payload& pl, SinkReplyBuilder* rb) {
CaptureVisitor cv{rb};
visit(cv, std::move(pl));
visit(cv, pl);
}

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

@augmentcode augmentcode 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.

Review completed. 1 suggestion posted.

Fix All in Augment

Comment augment review to trigger a new review at any time.

// function returns. ~BorrowedString releases the pin afterward — by then
// the source bytes are already in the kernel.
Flush();
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.

@augmentcode augmentcode 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.

Review completed. 1 suggestion posted.

Fix All in Augment

Comment augment review to trigger a new review at any time.

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.

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().

@@ -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?

// through replay.
virtual void SendBulkStringBorrowed(cmn::BorrowedString bs);
void SendBulkStringBorrowed(const cmn::BorrowedString& bs);
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?

// 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());
  }

@dranikpg
dranikpg force-pushed the squash-reply-scope branch from a0716f7 to efd3bf8 Compare June 30, 2026 08:23
dranikpg added 3 commits June 30, 2026 10:24
Signed-off-by: Vladislav Oleshko <vlad@dragonflydb.io>
@dranikpg
dranikpg force-pushed the squash-reply-scope branch from efd3bf8 to db79cb2 Compare June 30, 2026 08:24
@dranikpg
dranikpg requested a review from romange June 30, 2026 10:40
Comment thread src/facade/reply_builder.h Outdated
Signed-off-by: Roman Gershman <romange@gmail.com>
@dranikpg
dranikpg enabled auto-merge (squash) July 1, 2026 07:12
@dranikpg
dranikpg merged commit 3376be6 into dragonflydb:main Jul 1, 2026
17 of 23 checks passed
@dranikpg
dranikpg deleted the squash-reply-scope branch July 1, 2026 17:09
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