Skip to content

[Rust] Add persistent gRPC transport - #762

Open
elenagaljak-db wants to merge 5 commits into
mainfrom
stack/eos-grpc-core
Open

[Rust] Add persistent gRPC transport#762
elenagaljak-db wants to merge 5 commits into
mainfrom
stack/eos-grpc-core

Conversation

@elenagaljak-db

@elenagaljak-db elenagaljak-db commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

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:

  • Create once, then resume the same stream_id after recovery.
  • Stable logical offsets on the persistent wire protocol.
  • New ingestion beginning at last_committed_offset + 1 after process-level resume.
  • Landing-zone reconciliation for the lost-ack race where the server committed records before the client disconnected.
  • Local completion of reconciled waiters and callbacks, with only offsets above the watermark resent.
  • Existing backpressure, flush, close, callback, credential-refresh, and initial-retry behavior shared across transports.

The implementation remains behind eos. Its internal transport/recovery addition is recorded in rust/NEXT_CHANGELOG.md.

Stack

  1. [Rust] Add persistent stream proto #761 — protobuf contract
  2. [Rust] Add persistent gRPC transport #762 — gRPC transport and recovery engine
  3. [Rust] Expose persistent stream API #763 — public Rust API
  4. [Rust] Test persistent stream resume #764 — stateful mock and integration tests
  5. [Rust] Document persistent streams #765 — documentation and runnable example

How is this tested?

  • cargo check -p databricks-zerobus-ingest-sdk --features eos
  • cargo test -p databricks-zerobus-ingest-sdk --features eos --lib — 156 tests passed
  • End-to-end persistent coverage is added in [Rust] Test persistent stream resume #764.

Comment thread rust/sdk/Cargo.toml Outdated
]
# Zero-copy protobuf parser.
zeroparser = ["dep:self_cell", "dep:prost-build"]
# Persistent (Eos) streams; in development, API is unstable

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: (exactly-once semantics / EoS) just to be clearer maybe.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

Comment thread rust/sdk/src/landing_zone.rs Outdated
///
/// 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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Let's use <= maybe. 😄

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixd

Comment thread rust/sdk/src/stream/grpc/transport.rs Outdated
// `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()),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed the (sink, mode) double match. OutboundConnection now keeps the matching sender and request stream together, and open_rpc constructs the corresponding sink.

Comment thread rust/sdk/src/stream/grpc/transport.rs Outdated
}
}

fn send_failed() -> ZerobusError {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I renamed to ingest_failed

Comment thread rust/sdk/src/stream/grpc/transport.rs Outdated
}

fn send_failed() -> ZerobusError {
ZerobusError::StreamClosedError(tonic::Status::internal("Failed to send record"))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Probably pre-existing, but "Failed to send batch" is more suitable.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, I updated anyway

Comment thread rust/sdk/src/stream/grpc/transport.rs Outdated
}
#[cfg(feature = "eos")]
OutboundSink::Persistent(tx) => {
let payload = ingest_payload_to_persistent(batch.into_request_payload(offset_id));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we have a batch.into_persistent_request_payload or something like that?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added EncodedBatch::into_persistent_request_payload, matching the ephemeral conversion helper.

Comment thread rust/sdk/src/stream/grpc/transport.rs
Comment thread rust/sdk/src/stream/grpc/supervisor.rs Outdated
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should remove the Decision 6 part and in general comment can probably be shorter.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, removed

Comment thread rust/sdk/src/stream/grpc/connection.rs Outdated
pub(super) struct StreamConnection {
pub(super) sink: OutboundSink,
pub(super) inbound: InboundStream,
pub(super) stream_id: String,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two last fields are kind of duplicated with StreamInitInfo.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed

Comment thread rust/sdk/src/stream/grpc/transport.rs
// 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 = || {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should also note the idempotency hole here when create persistent stream fails but client gets no response from server?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added validation before touching the landing zone

Comment thread rust/sdk/src/stream/grpc/mod.rs Outdated
// 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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

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>
Signed-off-by: elenagaljak-db <elena.galjak@databricks.com>
Signed-off-by: elenagaljak-db <elena.galjak@databricks.com>

@teodordelibasic-db teodordelibasic-db left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shape looks good, let's just leave a couple of TODO comments for non-blocking hardening work for some edge cases.

Comment thread rust/sdk/Cargo.toml
]
# Zero-copy protobuf parser.
zeroparser = ["dep:self_cell", "dep:prost-build"]
# Persistent streams with exactly-once semantics (EoS); API is in development

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants