[Rust] Multiplexed stream ingest followups - #426
Conversation
|
@elenagaljak-db thanks for the review, i've decided to move the ack callback to another PR to not mix contexts here. I've pasted your comments regarding the ack callback on that PR and resolved the ones here |
4ead0b6 to
e3f3f1e
Compare
Signed-off-by: danilo-najkov-db <danilo.najkov@databricks.com>
e3f3f1e to
dae8ddf
Compare
| "MultiplexedStream poisoned due to sub-stream failure" | ||
| ); | ||
|
|
||
| let _admission = self.admission.write().await; |
There was a problem hiding this comment.
Not sure if we discussed this before.
Since we hold this lock across join_all(...flush()), an ingest already parked at admission.read().await in enqueue_reserved_admitted cannot acquire the read lock until the whole flush finishes, so it stays blocked for that window before it sees is_closed and errors, and because it also holds sync_mutex, other ingests on that sub-stream queue behind it.
The write lock maybe only needs to be held long enough to drain readers that are mid-enqueue, not for the whole flush. Could we take and immediately drop it as a barrier after setting is_closed, then run the flush and signal_shutdown() unlocked? Any reader that acquires the read lock afterward fails check_closed() anyway, so nothing new is admitted.
There was a problem hiding this comment.
make sense to not block ingests if stream is mid close/failure and return error. Fixed
|
|
||
| #[cfg(feature = "testing")] | ||
| pub(crate) fn capacity_wait_timeout(&self) -> std::time::Duration { | ||
| std::time::Duration::from_millis(self.options.flush_timeout_ms) |
There was a problem hiding this comment.
Do you think it would make sense to have a separate knob for this timeout other than the flush one?
There was a problem hiding this comment.
I added a seperate timeout variable, but didn't make it configureable for the clients. My thinking is that in the future we want the mux stream automatically try the next stream if the first one is full, so it will become an implementation detail the customer doesn't need to worry about. WDYT?
Signed-off-by: danilo-najkov-db <danilo.najkov@databricks.com>
Signed-off-by: danilo-najkov-db <danilo.najkov@databricks.com>
Signed-off-by: danilo-najkov-db <danilo.najkov@databricks.com>
Signed-off-by: danilo-najkov-db <danilo.najkov@databricks.com>
|
ill wait for #523 to be merged so I can fix the tests |
9b7bd97 to
065728e
Compare
Signed-off-by: danilo-najkov-db <danilo.najkov@databricks.com>
| } | ||
|
|
||
| #[allow(clippy::result_large_err)] | ||
| pub(crate) fn prepare_record_batch( |
There was a problem hiding this comment.
nit: What do you think about renaming to prepare_encoded_batch since that's the actual name of the returned structure and also RecordBatch is an Arrow concept?
| } | ||
|
|
||
| #[allow(clippy::result_large_err)] | ||
| pub(crate) fn prepare_records_batch<I, T>(&self, payload: I) -> ZerobusResult<EncodedBatch> |
There was a problem hiding this comment.
Ah the comment above kind of gets debunked by this method. Still, maybe some alternative could be applied to both methods, not sure...
There was a problem hiding this comment.
Still I can rename to something like prepare_encoded_batch
| let mut total_wait_ms = 0u64; | ||
| let mut logged_backpressure = false; | ||
| let started_at = tokio::time::Instant::now(); | ||
| let deadline = started_at + CAPACITY_WAIT_TIMEOUT; |
There was a problem hiding this comment.
The capacity deadline is always 30 seconds, so a stream configured with .flush_timeout_ms(100) still blocks for 30 seconds, while one using the default five-minute timeout starts failing much earlier. This also differs from the intended contract that the capacity wait uses the existing flush_timeout_ms option. Could we derive the deadline from the selected stream instead?
let capacity_wait_timeout =
std::time::Duration::from_millis(stream.options.flush_timeout_ms);
let deadline = started_at + capacity_wait_timeout;Could we also set flush_timeout_ms: Some(100) in test_ingest_times_out_when_capacity_never_recovers and assert the timeout after advancing past 100 ms? That would cover the configured contract rather than the fixed constant.
There was a problem hiding this comment.
flush() still behaves correctly.
If capacity is full because record A is unacknowledged:
- Record B waits for capacity.
- flush() snapshots record A.
- If A remains unacknowledged past flush_timeout_ms, flush() fails.
- If A is acknowledged, flush() may succeed and B may enqueue afterward.
That last case is intentional: records ingested concurrently with flush() are explicitly excluded. A capacity-waiting ingest has no offset yet, so it has not been admitted and cannot be part of the flush snapshot.
| let wait_duration = std::time::Duration::from_millis(backoff_ms) | ||
| .min(deadline.saturating_duration_since(now)); | ||
|
|
||
| match tokio::time::timeout(wait_duration, stream.reserve_capacity()).await { |
There was a problem hiding this comment.
tokio::time::timeout cancels stream.reserve_capacity() on every 1 to 50 ms poll. Tokio's semaphore is FIFO and documents that cancelling acquire_owned loses the waiter's place, so concurrent ingests repeatedly move to the back of the queue and can reach the capacity timeout even while permits are being released. Could we keep one reservation future alive for the whole wait and use separate timer branches only for liveness checks and the final deadline?
let reservation = stream.reserve_capacity();
tokio::pin!(reservation);
let mut health_check = tokio::time::interval(std::time::Duration::from_millis(50));
loop {
tokio::select! {
result = &mut reservation => {
return match result {
Ok(reservation) => Ok(reservation),
Err(error) => Err(self.handle_ingest_error(error, stream, idx).await),
};
}
_ = tokio::time::sleep_until(deadline) => {
return Err(ZerobusError::ConnectionTimeout(format!(
"Timed out waiting for capacity on multiplexed sub-stream {idx}",
)));
}
_ = health_check.tick() => {
self.check_closed()?;
// Keep the existing sub-stream liveness check here.
}
}
}There was a problem hiding this comment.
I fixed it by pinning one reservation future for the entire capacity wait. The diagnostic timeout only borrows that future and does not recreate the semaphore acquisition, so its FIFO position is preserved. Stream and mux cancellation tokens now handle liveness without periodic polling.
What changes are proposed in this pull request?
This fixes the
MultiplexedStreampoison/admission race and makes capacity waits bounded.The new ingest flow is:
The poison path acquires the mux admission write lock before flushing sub-streams, so no record can be admitted between the final closed check and the poison flush.
ZerobusStreamretains ownership of its internal ordering lock throughenqueue_reserved_admitted; the mux supplies only the admission check. Capacity reservations are opaque and cannot expose the underlying semaphore implementation.Capacity waits are bounded by the existing
flush_timeout_msoption. If acknowledgments never free capacity,ingest_record/ingest_recordsreturnZerobusError::ConnectionTimeoutinstead of waiting indefinitely. A capacity timeout does not poison an otherwise healthy mux.How is this tested?
ConnectionTimeoutafter the configured timeout.cargo fmt --all --checkcargo clippy -p databricks-zerobus-ingest-sdk --all-targets -- -D warnings