Repository navigation
fix(storage): mark async writer idle before invoking flush callbacks - #16504
Conversation
There was a problem hiding this comment.
Code Review
This pull request refactors the asynchronous buffered and resumed writer connections to mark the writer as idle under the lock before releasing it and notifying handlers, ensuring chained operations are dispatched immediately. It also adds corresponding unit tests to verify these callback behaviors. The review feedback points out a potential data race and crash in the newly added cross-thread flush tests, where worker.join() can be called before the continuation thread finishes initializing the worker thread object, and suggests waiting for the callback to complete before joining.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #16504 +/- ##
==========================================
+ Coverage 92.34% 92.39% +0.04%
==========================================
Files 2262 2262
Lines 217209 218382 +1173
==========================================
+ Hits 200592 201771 +1179
+ Misses 16617 16611 -6 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
64d9ee4 to
c0f601c
Compare
v-pratap
left a comment
There was a problem hiding this comment.
Thanks for addressing the earlier comments; the NeedsFlush() / ClearHandlersIfEmpty() changes and the new HWM/resume tests look good.
Because the writer is now idle while callbacks run, I found two new re-entrancy paths. Both apply to the buffered and resumed writers and are covered in the inline comments with repro tests. Each test fails on this PR and passes with the main-branch versions of the two .cc files:
- An earlier
Flush()future can complete after a later flush chained from a callback that completes inline. - After a permanent error raised inline from a callback, the trailing
StartWriting()sends one more RPC on the failed connection.
When a
Flush()completes onAsyncWriterConnectionResumedorAsyncWriterConnectionBuffered,OnQuery()delegates toSetFlushed().SetFlushed()satisfies pending flush promises (pending_flush_promises_) and notifiesWrite()calls that are blocked because the buffer reached the high watermark (flush_handlers_).Previously,
SetFlushed()satisfied the flush promises while the writer was still marked busy (state_ == State::kWritinginWriterConnectionResumed,writing_ == trueinWriterConnectionBuffered).OnQuery()only restarted the write loop afterSetFlushed()returned. As a result, any operation (Flush,Write,Finalize, orClose) chained synchronously from aFlush().then(...)continuation saw a busy writer and was queued instead of being dispatched before the callback returned.Running callbacks while the writer is idle exposed related issues, all raised in review:
Write()could cancel a queued flush.HandleNewData()recalculatedflush_from the buffer size alone. A smallWrite()issued while anotherFlush()was still pending setflush_ = false, which turned the queued flush into a plain write and left its future unsatisfied. On the resume path the reordering above newly exposed this, because the write loop used to restart before the callbacks ran.SetFlushed()released every blockedWrite()after any flush, even when the buffer was still at or above the low watermark. It also clearedflush_once noFlush()calls were pending, regardless of how much data remained buffered.Flush()futures (for exampleFlush(data)followed byFlush({})), aFlush()chained from the first continuation and completed inline by the underlying connection re-enteredSetFlushed()and satisfied its own future before the remaining ones from the outer call.StartWriting()did not checkresume_status_. If a chained operation failed inline with a permanent error,SetError()left the data in the buffer, and theStartWriting()call after the callbacks sent it again on the failed connection. InWriterConnectionResumedthe second failure also overwroteresume_status_(observed asCANCELLED: already finished).Changes
writer_connection_resumed.ccandwriter_connection_buffered.cc(identical changes):SetFlushed()flush_, and marks the writer idle (state_ = State::kIdle/writing_ = false), all while holdingmu_.NotifyAndStartWriting(), which delivers them withmu_released before restarting the write loop, so chained operations see an idle writer and are dispatched immediately.ClearHandlersIfEmpty()instead ofClearHandlers()).flush_ = NeedsFlush(lk), so the writer keeps flushing while waiters are still blocked.OnQuery()(is_resumebranch): applies the same idle-then-notify-then-restart sequence throughNotifyAndStartWriting()when notifying waiters on stream resume.NotifyAndStartWriting()helper: appends the handlers and flush promises to apending_notifications_queue and, unless a delivery loop is already running (notifying_), drains the queue in FIFO order with the lock released between callbacks, then callsStartWriting(). A nested call from a callback whose chained operation completed inline only appends to the queue, so earlier notifications are delivered first and the recursion depth is bounded. Chained operations are still dispatched immediately; only their notifications wait their turn.StartWriting(): returns early whenresume_status_is not OK, so no RPC is issued on a connection that already failed permanently.NeedsFlush()helper: returns true whileFlush()calls are pending, or while the buffer is non-empty and at or above the low watermark.HandleNewData()andSetFlushed()both use it, so a smallWrite()can no longer clearflush_while a flush is pending.ClearHandlersIfEmpty(): an empty buffer now always releases waiters. With a low watermark of 0 the previoussize >= lwmcheck was always true, so waiters were never released.Call Flow: Before vs. After
Before this PR
sequenceDiagram participant Impl as Underlying Stream (impl_) participant Writer as Buffered / Resumed Writer participant User as User f1.then() Callback Impl->>Writer: Flush(data1) completes -> OnQuery() -> SetFlushed() Note over Writer: Writer is still busy (kWriting / writing_ == true)<br/>Unlocks mu_ Writer->>User: f1.set_value(OK) activate User User->>Writer: Flush(data2) Note over Writer: HandleNewData() queues data2<br/>StartWriting() sees writer busy -> returns early Writer-->>User: returns future f2 (data2 NOT dispatched yet) User-->>Writer: callback returns deactivate User Note over Writer: OnQuery() restarts the write loop after SetFlushed() returns Writer->>Impl: impl_->Flush(data2) (dispatched after callback returns)After this PR
sequenceDiagram participant Impl as Underlying Stream (impl_) participant Writer as Buffered / Resumed Writer participant User as User f1.then() Callback Impl->>Writer: Flush(data1) completes -> OnQuery() -> SetFlushed() Note over Writer: Marks writer IDLE under mu_ (kIdle / writing_ = false)<br/>Unlocks mu_ Writer->>User: f1.set_value(OK) activate User User->>Writer: Flush(data2) Note over Writer: HandleNewData() queues data2<br/>StartWriting() sees writer idle -> dispatches immediately Writer->>Impl: impl_->Flush(data2) (in flight before callback returns) Writer-->>User: returns future f2 User-->>Writer: callback returns deactivate User Note over Writer: StartWriting() sees writer busy -> no-op