0c6e5b3ace
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
418 lines
17 KiB
Rust
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
|
|
}
|
|
}
|