[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.
| } | ||
| } | ||
|
|
||
| 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?
| } | ||
|
|
||
| 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.
| } | ||
| #[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?
|
|
||
| /// The inbound half of a stream: wraps the concrete tonic response stream and | ||
| /// yields normalized `InboundMessage`s. | ||
| pub(super) enum InboundStream { |
There was a problem hiding this comment.
Similar question as https://github.com/databricks/zerobus-sdk/pull/762/changes#r3851952011 if it makes sense, although here the sentiment is not as strong since we have no double matching. In general looks good regarding division of responsibilities, but I feel a bit we have two many enums where one variant is Ephemeral and the other is Persistent.
| 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.
| 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.
| } | ||
| } | ||
| #[cfg(feature = "eos")] | ||
| InboundStream::Persistent(s) => { |
There was a problem hiding this comment.
Mismatched setup responses currently succeed. Both CreateStreamResponse and ResumeStreamResponse are accepted regardless of whether the client requested a create or resume. The fallback in connection.rs:169 then lets a resume adopt the ID from an unexpected create response, while a create can accept an unexpected resume response without receiving a stream ID.
Could we preserve the response variant and validate it against the requested operation, for example:
enum Opened {
Created { stream_id: String },
Resumed { last_committed_offset: Option<i64> },
}The create path should only accept Opened::Created, and the resume path should only accept Opened::Resumed.
| // 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?
| }; | ||
| 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.
| // 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>
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