Files
mxaccessgw/clients/rust/src/client.rs
T
Joseph Doherty 0c6e5b3ace feat(clients): CLI-15 surface ReplayGap reconnect sentinel as typed signal (4/5 clients)
The gateway emits a ReplayGap sentinel MxEvent at the head of a StreamEvents
stream resumed via after_worker_sequence when the requested cursor predates the
oldest retained event. Clients previously ignored it, silently mis-treating a
lossy resume as continuous. Each client now surfaces the sentinel as a distinct,
typed, non-terminal signal (never synthesized, never swallowed) so a consumer can
detect the gap and re-snapshot; resume contract is
after_worker_sequence = oldest_available_sequence - 1.

- .NET: MxEventStreamItem (IsReplayGap/ReplayGap/Event) via new StreamEventItemsAsync
  + AsStreamItemsAsync extension. Build clean, 87 passed.
- Go: EventResult.ReplayGap field + IsReplayGap(); ReplayGap type alias. build/vet/test clean.
- Rust: EventItem enum (Event/ReplayGap); EventStream now yields Result<EventItem, Error>;
  CLI renders REPLAY_GAP line / replayGap JSON. fmt/check/test/clippy clean.
- Python: ReplayGap dataclass; stream_events yields pb.MxEvent | ReplayGap. 131 passed.
- Shared docs: ClientLibrariesDesign non-goals reframed (reconnect-replay protocol is
  consumable; auto-reconnect stays a non-goal); CrossLanguageSmokeMatrix resume-gap note.

Java client is deferred to the windev batch (no local JRE); CLI-15 stays open until it lands.

Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
2026-07-09 16:22:28 -04:00

418 lines
17 KiB
Rust

//! High-level wrapper around the generated `MxAccessGateway` gRPC client.
//!
//! [`GatewayClient::connect`] builds an authenticated `tonic` channel using
//! the supplied [`ClientOptions`], applies the bearer-token interceptor, and
//! exposes typed methods for the unary and streaming RPCs. Most application
//! code should prefer [`GatewayClient::open_session`] and the [`Session`]
//! handle it returns, rather than the `*_raw` methods.
use tonic::codegen::InterceptedService;
use tonic::transport::Channel;
use tonic::Request;
use crate::auth::AuthInterceptor;
use crate::error::{
ensure_command_success, ensure_mxaccess_success, ensure_protocol_success, Error,
};
use crate::generated::mxaccess_gateway::v1::mx_access_gateway_client::MxAccessGatewayClient;
use crate::generated::mxaccess_gateway::v1::{
AcknowledgeAlarmReply, AcknowledgeAlarmRequest, ActiveAlarmSnapshot, AlarmFeedMessage,
CloseSessionReply, CloseSessionRequest, MxCommandReply, MxCommandRequest, MxEvent,
OpenSessionReply, OpenSessionRequest, QueryActiveAlarmsRequest, ReplayGap, StreamAlarmsRequest,
StreamEventsRequest,
};
use crate::options::{build_tls_config, ClientOptions};
use crate::session::Session;
/// Generated gateway client wrapped in the auth interceptor that
/// [`GatewayClient`] uses internally.
pub type RawGatewayClient = MxAccessGatewayClient<InterceptedService<Channel, AuthInterceptor>>;
/// One item yielded by the per-session event stream returned by
/// [`GatewayClient::stream_events`].
///
/// Almost every item is an ordinary MXAccess event ([`EventItem::Event`]).
/// The one exception is the reconnect-replay gap sentinel
/// ([`EventItem::ReplayGap`]): the gateway emits it at most once, at the head
/// of a stream that was *resumed* via
/// [`Session::events_after`](crate::session::Session::events_after)
/// (`StreamEventsRequest.after_worker_sequence`) when the requested sequence is
/// older than the oldest event still retained in the session replay ring — i.e.
/// events were evicted and cannot be replayed.
///
/// The client does **not** synthesize this signal: it faithfully forwards the
/// gateway's sentinel `MxEvent` (whose `replay_gap` field is set), and only
/// makes it a distinct, typed variant so consumers can `match` on it instead of
/// inspecting a field on a value that otherwise looks like a normal event.
///
/// # Reacting to a gap
///
/// A [`EventItem::ReplayGap`] means "you missed events — discard any local
/// state and re-snapshot." The events in the open interval
/// `(requested_after_sequence, oldest_available_sequence)` are gone. To resume
/// the stream without provoking another gap, reconnect with
/// [`Session::events_after`](crate::session::Session::events_after) passing
/// `oldest_available_sequence - 1`, which replays starting at the first still
/// retained event (`oldest_available_sequence`):
///
/// ```no_run
/// # use zb_mom_ww_mxgateway_client::{EventItem, Session};
/// # use futures_util::StreamExt;
/// # async fn run(session: Session, cursor: u64) -> Result<(), zb_mom_ww_mxgateway_client::Error> {
/// let mut stream = session.events_after(cursor).await?;
/// while let Some(item) = stream.next().await {
/// match item? {
/// EventItem::Event(event) => {
/// let _ = event; // apply the change
/// }
/// EventItem::ReplayGap(gap) => {
/// // Local state is stale — re-snapshot, then resume without a gap.
/// let resume_cursor = gap.oldest_available_sequence.saturating_sub(1);
/// stream = session.events_after(resume_cursor).await?;
/// }
/// }
/// }
/// # Ok(())
/// # }
/// ```
// The `Event` variant is the hot path (nearly every stream item) and is the
// large one; the rare `ReplayGap` sentinel is small. Boxing `Event` to equalize
// the variants would add a heap allocation to every streamed event — a
// regression versus the prior `Result<MxEvent, Error>` surface, which already
// moved `MxEvent` by value. Keep the common path allocation-free.
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug, PartialEq)]
pub enum EventItem {
/// A normal MXAccess event forwarded from the worker.
Event(MxEvent),
/// The reconnect-replay gap sentinel — recent event history was evicted
/// before this resumed stream could replay it. See [`EventItem`] for how
/// to react.
ReplayGap(ReplayGap),
}
impl EventItem {
/// Classify an incoming `MxEvent` into the typed stream item.
///
/// A present `replay_gap` promotes the event to [`EventItem::ReplayGap`];
/// otherwise it is an ordinary [`EventItem::Event`]. The sentinel is never
/// dropped and never surfaced as a normal event.
fn from_event(mut event: MxEvent) -> Self {
match event.replay_gap.take() {
Some(gap) => EventItem::ReplayGap(gap),
None => EventItem::Event(event),
}
}
/// Borrow the inner [`MxEvent`] when this item is a normal event, or
/// `None` when it is the [`EventItem::ReplayGap`] sentinel.
#[must_use]
pub fn as_event(&self) -> Option<&MxEvent> {
match self {
EventItem::Event(event) => Some(event),
EventItem::ReplayGap(_) => None,
}
}
/// Borrow the [`ReplayGap`] when this item is the reconnect-replay gap
/// sentinel, or `None` for a normal event.
#[must_use]
pub fn replay_gap(&self) -> Option<&ReplayGap> {
match self {
EventItem::ReplayGap(gap) => Some(gap),
EventItem::Event(_) => None,
}
}
/// Consume the item and return the inner [`MxEvent`] when it is a normal
/// event, or `None` for the [`EventItem::ReplayGap`] sentinel.
#[must_use]
pub fn into_event(self) -> Option<MxEvent> {
match self {
EventItem::Event(event) => Some(event),
EventItem::ReplayGap(_) => None,
}
}
}
/// Pinned, boxed [`EventItem`] stream returned by
/// [`GatewayClient::stream_events`]. Each item is either a normal
/// [`EventItem::Event`] or the [`EventItem::ReplayGap`] reconnect-replay
/// sentinel. Errors are pre-mapped from `tonic::Status` to [`Error`]; dropping
/// the stream cancels the call.
pub type EventStream =
std::pin::Pin<Box<dyn futures_core::Stream<Item = Result<EventItem, Error>> + Send + 'static>>;
/// Pinned, boxed [`ActiveAlarmSnapshot`] stream returned by
/// [`GatewayClient::query_active_alarms`]. Errors are pre-mapped from
/// `tonic::Status` to [`Error`]; dropping the stream cancels the call.
pub type ActiveAlarmStream = std::pin::Pin<
Box<dyn futures_core::Stream<Item = Result<ActiveAlarmSnapshot, Error>> + Send + 'static>,
>;
/// Pinned, boxed [`AlarmFeedMessage`] stream returned by
/// [`GatewayClient::stream_alarms`]. Errors are pre-mapped from
/// `tonic::Status` to [`Error`]; dropping the stream cancels the call.
pub type AlarmFeedStream = std::pin::Pin<
Box<dyn futures_core::Stream<Item = Result<AlarmFeedMessage, Error>> + Send + 'static>,
>;
/// Thin async wrapper around the generated gateway client.
///
/// The wrapper is `Clone`: every clone shares the underlying tonic channel
/// (cheap, reference-counted) and the same call/stream timeouts. It is
/// designed to be cheap enough to clone per request handler.
#[derive(Clone)]
pub struct GatewayClient {
inner: RawGatewayClient,
call_timeout: std::time::Duration,
stream_timeout: Option<std::time::Duration>,
}
impl GatewayClient {
/// Connect to the gateway endpoint described by `options` and return a
/// ready-to-use client.
///
/// # Errors
///
/// Returns [`Error::InvalidEndpoint`] if the endpoint URL or CA file is
/// malformed, and [`Error::Transport`] if the TCP/TLS handshake fails.
pub async fn connect(options: ClientOptions) -> Result<Self, Error> {
let mut endpoint =
Channel::from_shared(options.endpoint().to_owned()).map_err(|source| {
Error::InvalidEndpoint {
endpoint: options.endpoint().to_owned(),
detail: source.to_string(),
}
})?;
endpoint = endpoint.connect_timeout(options.connect_timeout());
if let Some(tls) = build_tls_config(&options)? {
endpoint = endpoint.tls_config(tls)?;
}
let channel = endpoint.connect().await?;
let interceptor = AuthInterceptor::new(options.api_key().cloned());
let max_grpc_message_bytes = options.max_grpc_message_bytes();
Ok(Self {
inner: MxAccessGatewayClient::with_interceptor(channel, interceptor)
.max_decoding_message_size(max_grpc_message_bytes)
.max_encoding_message_size(max_grpc_message_bytes),
call_timeout: options.call_timeout(),
stream_timeout: options.stream_timeout(),
})
}
/// Borrow the underlying generated client. Use this only when you need
/// access to RPCs not surfaced by the wrapper.
pub fn raw_client(&mut self) -> &mut RawGatewayClient {
&mut self.inner
}
/// Consume the wrapper and return the underlying generated client.
pub fn into_inner(self) -> RawGatewayClient {
self.inner
}
/// Build a [`Session`] handle from a previously opened session id. No
/// RPC is performed — this is the cheap counterpart to
/// [`GatewayClient::open_session`] for callers that already own the id.
pub fn session(&self, session_id: impl Into<String>) -> Session {
Session::new(session_id, self.clone())
}
/// Issue an `OpenSession` RPC and return the raw reply without
/// validating its `protocol_status`.
///
/// # Errors
///
/// Returns the `tonic::Status` mapped through [`Error::from`].
pub async fn open_session_raw(
&self,
request: OpenSessionRequest,
) -> Result<OpenSessionReply, Error> {
let mut client = self.inner.clone();
let response = client.open_session(self.unary_request(request)).await?;
Ok(response.into_inner())
}
/// Open a session, validate its `protocol_status`, and return a typed
/// [`Session`] handle bound to this client.
///
/// # Errors
///
/// Returns [`Error::ProtocolStatus`] if the gateway accepts the call
/// but reports a non-OK protocol status, plus any of the
/// [`Error`] variants produced by [`open_session_raw`](Self::open_session_raw).
pub async fn open_session(&self, request: OpenSessionRequest) -> Result<Session, Error> {
let reply = self.open_session_raw(request).await?;
ensure_protocol_success("open session", reply.protocol_status.as_ref())?;
Ok(Session::new(reply.session_id, self.clone()))
}
/// Issue a `CloseSession` RPC and return the raw reply.
///
/// # Errors
///
/// Returns the `tonic::Status` mapped through [`Error::from`].
pub async fn close_session_raw(
&self,
request: CloseSessionRequest,
) -> Result<CloseSessionReply, Error> {
let mut client = self.inner.clone();
let response = client.close_session(self.unary_request(request)).await?;
Ok(response.into_inner())
}
/// Issue an `Invoke` RPC and return the raw reply, even when the
/// command-level protocol status is non-OK.
///
/// # Errors
///
/// Returns the `tonic::Status` mapped through [`Error::from`].
pub async fn invoke_raw(&self, request: MxCommandRequest) -> Result<MxCommandReply, Error> {
let mut client = self.inner.clone();
let response = client.invoke(self.unary_request(request)).await?;
Ok(response.into_inner())
}
/// Issue an `Invoke` RPC and surface a failing reply as a typed error.
///
/// The reply is validated twice: [`ensure_command_success`] rejects a
/// non-OK protocol envelope as [`Error::Command`], then
/// [`ensure_mxaccess_success`] rejects an MXAccess-level failure (negative
/// `hresult` or a non-success `MXSTATUS_PROXY` entry) as
/// [`Error::MxAccess`], preserving MXAccess parity. Callers that need the
/// unvalidated reply should use [`invoke_raw`](Self::invoke_raw) instead.
///
/// # Errors
///
/// Returns [`Error::Command`] when the reply's `protocol_status` is not
/// `Ok`, [`Error::MxAccess`] when the reply reports an MXAccess-level
/// failure, plus any errors propagated by
/// [`invoke_raw`](Self::invoke_raw).
pub async fn invoke(&self, request: MxCommandRequest) -> Result<MxCommandReply, Error> {
let reply = ensure_command_success(self.invoke_raw(request).await?)?;
ensure_mxaccess_success(reply)
}
/// Open the server-streaming `StreamEvents` RPC.
///
/// The returned [`EventStream`] yields [`EventItem`] values as the worker
/// produces them: ordinary MXAccess events as [`EventItem::Event`], and the
/// gateway's reconnect-replay gap sentinel — set only on resumed streams
/// whose requested sequence predates the retained replay history — as
/// [`EventItem::ReplayGap`]. Dropping the stream cancels the gRPC call
/// cooperatively.
///
/// # Errors
///
/// Returns the `tonic::Status` mapped through [`Error::from`] if the
/// server rejects the subscription.
pub async fn stream_events(&self, request: StreamEventsRequest) -> Result<EventStream, Error> {
let mut client = self.inner.clone();
let response = client.stream_events(self.stream_request(request)).await?;
let stream = futures_util::StreamExt::map(response.into_inner(), |result| {
result.map(EventItem::from_event).map_err(Error::from)
});
Ok(Box::pin(stream))
}
/// Acknowledge an active MXAccess alarm condition through the gateway.
///
/// The gateway authenticates the request against the API key's
/// `invoke:alarm-ack` scope and forwards the acknowledge to the worker's
/// MXAccess session; the resulting native MxStatus is returned in the
/// reply. Acks are idempotent at the MxAccess layer.
///
/// # Errors
///
/// Returns [`Error::ProtocolStatus`] when the gateway accepts the call but
/// reports a non-OK protocol status, plus any of the [`Error`] variants
/// produced by transport failures.
pub async fn acknowledge_alarm(
&self,
request: AcknowledgeAlarmRequest,
) -> Result<AcknowledgeAlarmReply, Error> {
let mut client = self.inner.clone();
let response = client
.acknowledge_alarm(self.unary_request(request))
.await?;
let reply = response.into_inner();
ensure_protocol_success("acknowledge alarm", reply.protocol_status.as_ref())?;
Ok(reply)
}
/// Open the server-streaming `QueryActiveAlarms` RPC — the gateway's
/// ConditionRefresh equivalent.
///
/// The returned [`ActiveAlarmStream`] yields one [`ActiveAlarmSnapshot`]
/// per currently-active alarm. Dropping the stream cancels the gRPC call
/// cooperatively. Optional alarm-reference prefix scoping
/// (`request.alarm_filter_prefix`) limits the stream to a sub-tree.
///
/// # Errors
///
/// Returns the `tonic::Status` mapped through [`Error::from`] if the
/// server rejects the request.
pub async fn query_active_alarms(
&self,
request: QueryActiveAlarmsRequest,
) -> Result<ActiveAlarmStream, Error> {
let mut client = self.inner.clone();
let response = client
.query_active_alarms(self.stream_request(request))
.await?;
let stream = futures_util::StreamExt::map(response.into_inner(), |result| {
result.map_err(Error::from)
});
Ok(Box::pin(stream))
}
/// Attach to the gateway's central `StreamAlarms` feed.
///
/// The returned [`AlarmFeedStream`] opens with one [`AlarmFeedMessage`]
/// per currently-active alarm (the ConditionRefresh snapshot), then a
/// single `snapshot_complete`, then a `transition` for every subsequent
/// raise / acknowledge / clear. It is served by the gateway's always-on
/// alarm monitor — no worker session is opened — so any number of clients
/// may attach. Dropping the stream cancels the gRPC call cooperatively.
/// Optional alarm-reference prefix scoping (`request.alarm_filter_prefix`)
/// limits the stream to a sub-tree.
///
/// # Errors
///
/// Returns the `tonic::Status` mapped through [`Error::from`] if the
/// server rejects the request.
pub async fn stream_alarms(
&self,
request: StreamAlarmsRequest,
) -> Result<AlarmFeedStream, Error> {
let mut client = self.inner.clone();
let response = client.stream_alarms(self.stream_request(request)).await?;
let stream = futures_util::StreamExt::map(response.into_inner(), |result| {
result.map_err(Error::from)
});
Ok(Box::pin(stream))
}
fn unary_request<T>(&self, message: T) -> Request<T> {
let mut request = Request::new(message);
request.set_timeout(self.call_timeout);
request
}
fn stream_request<T>(&self, message: T) -> Request<T> {
let mut request = Request::new(message);
if let Some(timeout) = self.stream_timeout {
request.set_timeout(timeout);
}
request
}
}