[c++] Add write callbacks with batch completion dispatch - #4328
Conversation
db30250 to
e0d7dd6
Compare
|
@fresh-borzoni @loserwang1024 @leekeiabstraction, Appreciate a review here, thanks! 🙏 |
fresh-borzoni
left a comment
There was a problem hiding this comment.
@naivedogger Thank you, left a comment, PTAL
|
@fresh-borzoni thanks for the earlier review. I pushed three commits on top. enqueue_timeout now bounds the whole callback submission, both callback capacity and buffer backpressure, in the max.block.ms style, and a zero timeout makes submission non-blocking. The callback executor thread count is now overridable through an advanced FLUSS_CALLBACK_WORKERS variable, with the default of four unchanged. I also expanded the docs and example around callback behavior: callbacks run on a small shared pool and should stay short and non-blocking, failures should be recorded and handled outside the callback with dedup by id, and crash recovery should come from a replayable source that advances only after Flush. When you have time, another look would be appreciated. |
77113f3 to
ac3775e
Compare
Make WriteCallbackOptions::enqueue_timeout cover the whole submission, both callback capacity and buffer backpressure, following the Kafka max.block.ms model. The callback path passes a submit budget through the FFI so the Rust buffer-memory wait is bounded by the remaining budget, while the public overloads keep the writer's configured buffer wait timeout. A zero timeout makes submission non-blocking.
Read the process-wide callback executor thread count from the advanced FLUSS_CALLBACK_WORKERS environment variable, falling back to the default when it is unset, invalid, or zero. This is a rarely needed escape hatch; the default of four workers is unchanged.
Explain that callbacks run on a small shared executor pool and must stay short and non-blocking, and note the advanced FLUSS_CALLBACK_WORKERS knob. Clarify failure handling: record the outcome and either stop or retry outside the callback, deduplicating by identifier, and drive crash recovery from a replayable source that advances only after Flush. Update the enqueue_timeout wording and the example comments to match.
ac3775e to
f332fe3
Compare
I do think client.writer.buffer.wait-timeout is enough, no need top. enqueue_timeout anymore |
Remove the WriteCallbackOptions enqueue_timeout field and route the whole callback submit through the connection's client.writer.buffer.wait-timeout. The capacity reservation and the buffer-backpressure wait now share one deadline sourced from that setting, so a submit returns within a single timeout instead of two. UINT64_MAX keeps the default unbounded and a zero timeout makes submission non-blocking.
…bmit Set writer_buffer_wait_timeout_ms in the example config so its role is visible: it bounds both the write-buffer wait and the whole callback submission. Add an end-to-end test that fills a writer's capacity with a blocking callback and asserts the next submit returns with a timeout error after the configured budget, not after the callback finally releases the slot.
|
@loserwang1024 I think you’re right. I’ve updated it to use |
fresh-borzoni
left a comment
There was a problem hiding this comment.
@naivedogger Thank you, LGTM 👍
loserwang1024
left a comment
There was a problem hiding this comment.
I have left some advice
| /// writers or large captures; independent of Configuration::writer_buffer_memory_size. | ||
| /// Waiting for a free slot is bounded by client.writer.buffer.wait-timeout, the same | ||
| /// budget as the buffer-backpressure wait. | ||
| size_t max_pending_operations = 262144; |
There was a problem hiding this comment.
On WriteCallbackOptions.max_pending_operations
I'd like to confirm my understanding of the intended role of max_pending_operations — overall the design makes sense to me, but a few questions:
1. Relationship with client.writer.buffer.memory-size
From reading the code: memory permits are held until the batch completes (ACK), while max_pending_operations is reserved at submission and released only after the callback finishes executing on a worker. So the window it covers is really "after ACK, while the completion is queued or executing on the shared executor" — where captures are held in the (intentionally unbounded) completion queue with no other accounting.
So is the main failure mode it guards against slow user callbacks (submissions outpacing the 4-worker consumption rate, so callbacks pile up), rather than data volume? It would help to state this explicitly in the docs: memory-size bounds buffered bytes, max_pending_operations bounds outstanding callback operations (captures), and neither substitutes for the other.
2. Why does only C++ need this?
Neither the Java nor the Rust client has an equivalent. My reading is that this is inherent to this design rather than an oversight:
- Java returns a
CompletableFutureand the SDK's responsibility ends atfuture.complete()— callback scheduling runs on a user-supplied executor, so the SDK never queues callbacks internally; - Here the dispatch happens on the IO path, so the binding hosts a shared executor with an unbounded queue (a slow callback must not block IO), and an unbounded internal queue then requires admission control at the source.
Is that the intent? As a follow-up (not necessarily for this PR): would you consider an overload that lets the caller inject their own executor (e.g., Notify(cb, executor) or an executor field in WriteCallbackOptions), so advanced users can own the queuing the way Java users do and bypass the shared pool?
3. Sizing guidance for production
Do you have recommended values? Two related observations:
- The default 262144 is per writer, while the executor and its queue are process-wide, so with N writers the worst-case backlog is N × max.
- More importantly, the count-based limit and the byte-based limit don't derive from each other: with a small average record size (or a large
writer_buffer_memory_size), the number of in-flight records the buffer can hold far exceeds 262144 — e.g. 512 MiB / 200 B ≈ 2.6M — so a callback submission can hit the capacity limit long before the buffer is full, andAppendblocks inAcquire(bounded by wait-timeout) even though plenty of buffer memory is still free.
Is that "capacity binds before memory" behavior intended, and is it documented? And could the docs include a concrete derivation, e.g.:
- lower bound:
max_pending_operations ≳ peak throughput × (ack latency + callback execution time / workers)so the limit never binds under normal load; - upper bound:
max_pending_operations × average capture size ≤ callback memory budget.
The "Sizing callback capacity and write buffers" section hints at this, but an explicit formula would make it much easier to configure.
There was a problem hiding this comment.
Removed the public capacity option and now derive the internal callback limit from the writer buffer and schema estimate.
|
|
||
| /// Like [`Self::upsert`], but bounds the buffer-memory wait by `deadline`. A deadline | ||
| /// already in the past makes the submit fail fast when the buffer is full. | ||
| pub fn upsert_with_deadline<R: InternalRow>( |
There was a problem hiding this comment.
why need this in this PR? If you want to introduce deadline, maybe another PR (and do it in both java and rust client)
There was a problem hiding this comment.
Kept this only as doc-hidden binding plumbing to share the existing writer buffer wait budget, without adding a public deadline API.
|
|
||
| // Like RUNTIME, the executor is process-wide and lives until process exit. | ||
| // Initialize it on the submitting thread, not an async I/O worker. | ||
| static CALLBACK_EXECUTOR: LazyLock<Option<CallbackExecutor>> = |
There was a problem hiding this comment.
This is a blocking concern for me: callbacks for the same bucket must fire in completion order. I can't accept out-of-order notification within a single bucket, regardless of whether it's documented as intended behavior.
Worth spelling out where the ordering is actually lost, because it sharpens the point:
- Same-bucket batches already complete in order, end to end: per-bucket in-flight is serialized, so batch2 isn't even sent until batch1 is ACKed.
- ACKs on the same connection are processed in order, so the completion job for batch1 is enqueued before batch2's.
- The channel itself is FIFO.
So ordering is destroyed in exactly one place: multiple workers dequeuing in parallel. The server, the sender, and the queue all preserve order — the executor is the first and only component to throw it away. Users then pay for it with locks plus order-defensive logic (advance by max, never by assignment) in exactly the most common callback patterns: per-bucket state and offset/checkpoint tracking. For reference, Kafka users get serialized onCompletion on the sender thread, and Fluss Java users get non-async thenAccept on the completing thread by default — this PR hands C++ users strictly less than both. (A late Notify on an already-completed batch enqueues at registration time — that's user-side causality and orthogonal to completion ordering.)
Ask for this PR: default to a single dispatch worker.
- FIFO queue + single consumer = callbacks fire in completion order (globally, which subsumes per-bucket). The change is small: worker count → 1. Chunking can stay or be simplified — it only existed to spread work across workers.
- The throughput concern has a clean answer, and it should become part of the contract: callbacks are notifications, not work items. The docs should state explicitly that callbacks must be lightweight — no heavy compute or IO; if you need that, hand off to your own thread pool. This is exactly the guidance Kafka gives for
onCompletion, and the situation here is strictly milder than Kafka's: a slow callback only delays other callbacks — it never touches the IO path or write throughput (the queue is unbounded by design, andmax_pending_operationsbounds the backlog).
Evolution path (no API change): if dispatch parallelism is ever needed, shard the executor — key by writer or bucket id, one queue and one worker per key. That restores per-key parallelism while keeping per-key FIFO, and Notify/WriteCallbackOptions don't change. Until there's a demonstrated need, I'd keep it simple.
Related: with a single worker, queue drain time becomes the dominant term in AwaitAll's budget, so the hardcoded 60s in Flush() — and its error conflating "flushed successfully" with "callbacks didn't drain" — becomes easier to trigger. Please fix that alongside whichever direction we take here.
There was a problem hiding this comment.
Changed dispatch to one FIFO worker, added a same-bucket ordering test, and made Flush wait on an unbounded callback barrier.
|
@loserwang1024 Thanks for your thorough review! I have pushed an update to address your points, could you please take another look? |
fresh-borzoni
left a comment
There was a problem hiding this comment.
@naivedogger looked through as well, left some comments, PTAL
Not from your PR, but followup issue in the same path:
AppendArrowBatch sends one bucket group at a time. If a send fails partway, the earlier groups are already queued and get written, but the caller only sees an erro and resending duplicates those rows. Idempotence does not help her as it dedupes internal retries, not a fresh append.
The plain path does the same, and Java has no multi-bucket append so it is unaffected.
I would propose two steps in a separate issue. Make Err mean nothing was accepted, returning Ok with a joined future carrying the error when some groups went through. Then surface per-bucket results: PbProduceLogRespForBucket already carries bucket_id, error_code and base_offset, but BatchWriteResult is Result<(), Error> and drops them, so a caller cannot retry only the buckets that failed.
Separately, +1 to @loserwang1024 's injectable executor idea. C++'s default is already better than Java's here since it's ordered and off the I/O thread, but Java users can opt out with thenAcceptAsync and C++ users have no equivalent. It would also hand the queue bound to whoever owns the executor, which removes the need to guess max_pending_operations.
made a thorough pass over the code one more time
|
@fresh-borzoni Thanks for the review. I've pushed an update with I've kept the single callback worker, fixed the late-registration ordering race, and removed the parallel delivery fallback. I've also corrected the stale four-worker documentation, updated the examples, and removed the timed For ArrowBatch, I agree with handling this separately in the two steps you suggested. First, return a synchronous |
|
LGTM, @fresh-borzoni @leonardBang , would you like to give a final check. |
fresh-borzoni
left a comment
There was a problem hiding this comment.
+1, I'll wait until CI is green to merge
Purpose
Linked issue: close #4276
Add C++ write callbacks so applications can handle write completion without maintaining a separate WriteResult waiting queue.
Brief change log
Tests
API and Format
Documentation
Update the C++ API reference with callback examples, compatibility notes, partial-failure semantics, and application responsibilities for memory limits and shutdown.