[Rust] Add persistent gRPC transport - #762
Conversation
98b4072 to
9dcabe6
Compare
4ff9401 to
fd8c70d
Compare
9dcabe6 to
18a643f
Compare
1ffcd2e to
82a81bf
Compare
18a643f to
abf5a92
Compare
abf5a92 to
12c4957
Compare
| ] | ||
| # Zero-copy protobuf parser. | ||
| zeroparser = ["dep:self_cell", "dep:prost-build"] | ||
| # Persistent (Eos) streams; in development, API is unstable |
There was a problem hiding this comment.
nit: (exactly-once semantics / EoS) just to be clearer maybe.
| /// | ||
| /// Used by the persistent-stream resume path to reconcile the retained tail | ||
| /// against the server's committed watermark: it removes the prefix of | ||
| /// records the server has already durably stored (offset ≤ resume watermark) |
There was a problem hiding this comment.
nit: Let's use <= maybe. 😄
| // `make_outbound`, so a mismatch is unreachable. Only present when | ||
| // more than one variant exists (i.e. `eos` is enabled). | ||
| #[cfg(feature = "eos")] | ||
| _ => Err(Self::open_failed()), |
There was a problem hiding this comment.
Hm can the OutboundSink and TransportKind enums be merged somehow so we don't have to do this double matching? I don't have a specific idea in mind.
There was a problem hiding this comment.
I removed the (sink, mode) double match. OutboundConnection now keeps the matching sender and request stream together, and open_rpc constructs the corresponding sink.
| } | ||
| } | ||
|
|
||
| fn send_failed() -> ZerobusError { |
There was a problem hiding this comment.
nit: The naming matrix of these two methods and their helper error methods are a bit mixed up I'd say.
send_open - open_failed.
send_ingest - send_failed, I'd say the second one here should be ingest_failed. I get the sentiment behind send_failed, we are sending a batch, but WDYT?
There was a problem hiding this comment.
I renamed to ingest_failed
| } | ||
|
|
||
| fn send_failed() -> ZerobusError { | ||
| ZerobusError::StreamClosedError(tonic::Status::internal("Failed to send record")) |
There was a problem hiding this comment.
nit: Probably pre-existing, but "Failed to send batch" is more suitable.
There was a problem hiding this comment.
Yep, I updated anyway
| } | ||
| #[cfg(feature = "eos")] | ||
| OutboundSink::Persistent(tx) => { | ||
| let payload = ingest_payload_to_persistent(batch.into_request_payload(offset_id)); |
There was a problem hiding this comment.
Can we have a batch.into_persistent_request_payload or something like that?
There was a problem hiding this comment.
Added EncodedBatch::into_persistent_request_payload, matching the ephemeral conversion helper.
| let resent_records = landing_zone_recovery.observed_count(); | ||
| let resent_batches = landing_zone_recovery.reset_observe(); | ||
|
|
||
| // Resume alignment (persistent streams). The server reports how far |
There was a problem hiding this comment.
We should remove the Decision 6 part and in general comment can probably be shorter.
There was a problem hiding this comment.
Yep, removed
| pub(super) struct StreamConnection { | ||
| pub(super) sink: OutboundSink, | ||
| pub(super) inbound: InboundStream, | ||
| pub(super) stream_id: String, |
There was a problem hiding this comment.
These two last fields are kind of duplicated with StreamInitInfo.
| // the one-shot limit; reconnect keeps its existing timeout-wrapped path unchanged. | ||
| let is_initial = initial_stream_creation; | ||
| let attempt = AtomicUsize::new(0); | ||
| let create_attempt = || { |
There was a problem hiding this comment.
Maybe we should also note the idempotency hole here when create persistent stream fails but client gets no response from server?
There was a problem hiding this comment.
Added a comment documenting the remaining hole: if create succeeds server-side but its response is lost, retrying may create an orphan because no client-supplied ID exists yet. Resume remains idempotent.
| }; | ||
| let mut last_logical_acked_offset = -2; | ||
| let mut map = oneshot_map.lock().await; | ||
| for _offset_to_ack in (last_acked_offset + 1)..=durability_ack_up_to_offset |
There was a problem hiding this comment.
Let's reject an ACK lower than last_acked_offset or higher than the greatest sent wire offset before removing anything from landing_zone and close the stream on either protocol violation. We added such checks recently to the Arrow Flight SDK. It's an edge case for a malformed server, but worth just filling all the gaps.
There was a problem hiding this comment.
Added validation before touching the landing zone
| // Safe because the constructor returns before any user ingest, so no real | ||
| // ack can race this initial value. | ||
| if let Some(watermark) = init_info.last_committed_offset { | ||
| logical_offset_id_generator.set_next(watermark + 1); |
There was a problem hiding this comment.
Let's use checked arithmetic here. A peer can return i64::MAX for this int64 field, at which point watermark + 1 panics when overflow checks are enabled and can wrap to a negative offset otherwise.
let next_offset = watermark.checked_add(1).ok_or_else(|| {
ZerobusError::UnexpectedStreamResponseError(
"Persistent stream offset space is exhausted".to_string(),
)
})?;
logical_offset_id_generator.set_next(next_offset);Signed-off-by: elenagaljak-db <elena.galjak@databricks.com>
Signed-off-by: elenagaljak-db <elena.galjak@databricks.com>
Signed-off-by: elenagaljak-db <elena.galjak@databricks.com>
a574b4e to
137f398
Compare
Signed-off-by: elenagaljak-db <elena.galjak@databricks.com>
Signed-off-by: elenagaljak-db <elena.galjak@databricks.com>
teodordelibasic-db
left a comment
There was a problem hiding this comment.
Shape looks good, let's just leave a couple of TODO comments for non-blocking hardening work for some edge cases.
| ] | ||
| # Zero-copy protobuf parser. | ||
| zeroparser = ["dep:self_cell", "dep:prost-build"] | ||
| # Persistent streams with exactly-once semantics (EoS); API is in development |
There was a problem hiding this comment.
nit: Period at the end.
| // Mutated only on the persistent recovery path (flip create → resume); | ||
| // that mutation is `eos`-gated, so `mut` is otherwise unused. | ||
| #[cfg_attr(not(feature = "eos"), allow(unused_mut))] mut kind: GrpcConnectionMode, | ||
| mut kind: GrpcConnectionMode, |
There was a problem hiding this comment.
Why did we remove the feature gating for mutability, also is the comment above now stale?
| // remaining observed records for resend. A first-process resume has | ||
| // an empty landing zone and only seeds the receiver watermark. | ||
| let mut initial_last_acked_offset: OffsetId = -1; | ||
| if let Some(watermark) = last_committed_offset { |
There was a problem hiding this comment.
Let's just leave a TODO immediately before this watermark handling so the missing validation is not lost? It should record that automatic resume must validate the normalized watermark against the previous ACK and highest successfully sent wire offset before reconciling, including None as the no-commit position -1.
// TODO: Validate the resumed watermark against retained ACK and sent-offset
// bounds before reconciling; treat None as -1 and fail closed if out of range.| )); | ||
| let _ = server_error_tx.send(Some(error.clone())); | ||
| return Err(error); | ||
| landing_zone.observed_count(), |
There was a problem hiding this comment.
Let's leave a TODO at the observed_count() argument noting that ACK bounds need the highest successfully sent wire offset rather than a landing-zone count. observed_count() counts records even though a batch has one wire offset, and counting observed batches alone is also insufficient because observe runs before the send succeeds.
// TODO: Bound ACKs by the highest successfully sent wire offset, not landing-zone
// counts; batches use one offset and observation precedes a successful send.| // since the server never re-acks offsets it committed in a prior session. | ||
| // Safe because the constructor returns before any user ingest, so no real | ||
| // ack can race this initial value. | ||
| if let Some(watermark) = init_info.last_committed_offset { |
There was a problem hiding this comment.
Let's leave a TODO before this fallible post-initialization block noting that the supervisor and optional callback task must be cleaned up if watermark validation fails. Dropping their JoinHandles detaches them rather than cancelling them.
// TODO: Validate the resume watermark before spawning tasks, or cancel and abort
// the spawned tasks on post-initialization errors so their handles are not detached.
What changes are proposed in this pull request?
This PR implements the internal gRPC transport and recovery machinery for persistent streams. It deliberately does not expose a new public SDK type; that surface is added in #763.
The sender, receiver, connection, and supervisor tasks are shared between ephemeral and persistent streams through a typed transport seam. Persistent streams use the dedicated RPC while ephemeral behavior remains unchanged.
Persistent behavior includes:
stream_idafter recovery.last_committed_offset + 1after process-level resume.The implementation remains behind
eos. Its internal transport/recovery addition is recorded inrust/NEXT_CHANGELOG.md.Stack
How is this tested?
cargo check -p databricks-zerobus-ingest-sdk --features eoscargo test -p databricks-zerobus-ingest-sdk --features eos --lib— 156 tests passed