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.

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. 😄

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.

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?

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.

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?


/// The inbound half of a stream: wraps the concrete tonic response stream and
/// yields normalized `InboundMessage`s.
pub(super) enum InboundStream {

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.

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.

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.

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.

}
}
#[cfg(feature = "eos")]
InboundStream::Persistent(s) => {

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.

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 = || {

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?

};
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.

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

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