Files
mxaccessgw/clients/rust/src/session.rs
T
Joseph Doherty dd623356f4 docs(clients): ReadBulk timeout is one shared deadline for the command, not per tag
Stream E made the worker wait once for all uncached tags. Fix the Go, Python, Rust, Java
and .NET library docs and CLI help text, the proto comment (default is the worker's
1000 ms), and the worker design doc. Regenerated Contracts Generated/ and the Java
generated protos; both diffs are comment-only.
2026-10-22 08:46:29 -04:00

1349 lines
47 KiB
Rust

//! Typed handle around an opened gateway session.
//!
//! [`Session`] wraps an `OpenSession` reply (just the session id) plus a
//! cloned [`GatewayClient`] and offers Rust-shaped methods for the
//! command surface that the worker exposes — `Register`, `AddItem`,
//! bulk subscribe variants, `Write`/`Write2`, and the event stream.
//!
//! Bulk commands enforce a 1000-item cap before contacting the worker, in
//! line with the gateway's documented `MAX_BULK_ITEMS`.
use std::sync::atomic::{AtomicU64, Ordering};
use crate::client::{EventStream, GatewayClient};
use crate::error::{ensure_protocol_success, Error, MxAccessError};
use crate::generated::mxaccess_gateway::v1::mx_command::Payload;
use crate::generated::mxaccess_gateway::v1::mx_command_reply;
use crate::generated::mxaccess_gateway::v1::{
ActivateCommand, AddBufferedItemCommand, AddItem2Command, AddItemBulkCommand, AddItemCommand,
AdviseCommand, AdviseItemBulkCommand, AdviseSupervisoryCommand, ArchestrAUserToIdCommand,
AuthenticateUserCommand, BulkReadResult, BulkWriteResult, CloseSessionRequest,
DrainEventsCommand, GetSessionStateCommand, GetWorkerInfoCommand, MxCommand, MxCommandKind,
MxCommandReply, MxCommandRequest, MxDataType, MxEvent, MxSparseArray, MxSparseElement,
MxValue as ProtoMxValue, OpenSessionRequest, PingCommand, ReadBulkCommand, RegisterCommand,
RemoveItemBulkCommand, RemoveItemCommand, SessionState, SetBufferedUpdateIntervalCommand,
StreamEventsRequest, SubscribeBulkCommand, SubscribeResult, SuspendCommand, UnAdviseCommand,
UnAdviseItemBulkCommand, UnregisterCommand, UnsubscribeBulkCommand, WorkerInfoReply,
Write2BulkCommand, Write2BulkEntry, Write2Command, WriteBulkCommand, WriteBulkEntry,
WriteCommand, WriteSecured2BulkCommand, WriteSecured2BulkEntry, WriteSecured2Command,
WriteSecuredBulkCommand, WriteSecuredBulkEntry, WriteSecuredCommand,
};
use crate::value::{MxStatus, MxValue, MxValueProjection};
const MAX_BULK_ITEMS: usize = 1_000;
/// Process-wide monotonic sequence used by [`next_correlation_id`].
static CORRELATION_SEQUENCE: AtomicU64 = AtomicU64::new(1);
/// Build a per-call correlation id that embeds the supplied `label`.
///
/// The returned token is opaque and guaranteed to be unique within the
/// current process: every call increments a process-wide atomic counter,
/// so concurrent CLI smokes and library callers on the same machine produce
/// distinct ids that gateway logs can tell apart. The token carries no
/// embedded secret beyond `label`.
///
/// The exact textual format (currently `rust-client-{label}-{N}`) is *not*
/// part of the public contract — callers must not parse it. The crate root
/// re-exports this helper as
/// [`zb_mom_ww_mxgateway_client::next_correlation_id`] so out-of-tree
/// consumers can build correlation ids without referencing the `session`
/// module path.
pub fn next_correlation_id(label: &str) -> String {
let sequence = CORRELATION_SEQUENCE.fetch_add(1, Ordering::Relaxed);
format!("rust-client-{label}-{sequence}")
}
/// Handle to an opened gateway session.
///
/// `Session` carries the gateway-issued session id and a cloned
/// [`GatewayClient`]. All methods are async and stateless on the client
/// side: the gateway tracks per-session worker state. Drop the handle and
/// call [`Session::close`] to release the worker; the gateway also reaps
/// orphaned sessions on its own schedule.
#[derive(Clone)]
pub struct Session {
id: String,
client: GatewayClient,
}
impl Session {
pub(crate) fn new(id: impl Into<String>, client: GatewayClient) -> Self {
Self {
id: id.into(),
client,
}
}
/// Borrow the gateway-assigned session id.
pub fn id(&self) -> &str {
&self.id
}
/// Convenience constructor that issues `OpenSession` with the supplied
/// client session name and returns the resulting [`Session`].
///
/// # Errors
///
/// Propagates errors from
/// [`GatewayClient::open_session`](crate::client::GatewayClient::open_session).
pub async fn open(client: GatewayClient, client_session_name: &str) -> Result<Self, Error> {
client
.open_session(OpenSessionRequest {
client_session_name: client_session_name.to_owned(),
..OpenSessionRequest::default()
})
.await
}
/// Issue `CloseSession` against this session id.
///
/// # Errors
///
/// Returns [`Error::ProtocolStatus`] if the gateway returns a non-OK
/// envelope and any transport/status errors propagated by tonic.
pub async fn close(&self) -> Result<(), Error> {
let reply = self
.client
.close_session_raw(CloseSessionRequest {
session_id: self.id.clone(),
client_correlation_id: next_correlation_id("close-session"),
})
.await?;
ensure_protocol_success("close session", reply.protocol_status.as_ref())?;
Ok(())
}
/// Run MXAccess `Register` and return the assigned `ServerHandle`.
///
/// # Errors
///
/// Returns [`Error::Command`] if the worker reports a non-OK status,
/// plus transport/status errors from tonic.
pub async fn register(&self, client_name: &str) -> Result<i32, Error> {
let reply = self
.invoke(
MxCommandKind::Register,
Payload::Register(RegisterCommand {
client_name: client_name.to_owned(),
}),
)
.await?;
register_server_handle(&reply)
}
/// Run MXAccess `Unregister` to release the given `ServerHandle` and the
/// items advised under it. Mirrors [`Session::register`]; the worker
/// returns no payload, so the call resolves to `()` on success.
///
/// # Errors
///
/// Returns [`Error::Command`] if the worker reports a non-OK protocol
/// status and [`Error::MxAccess`] if MXAccess itself rejects the
/// unregister (negative `hresult` / non-success status), plus the usual
/// transport/status errors.
pub async fn unregister(&self, server_handle: i32) -> Result<(), Error> {
self.invoke(
MxCommandKind::Unregister,
Payload::Unregister(UnregisterCommand { server_handle }),
)
.await?;
Ok(())
}
/// Run MXAccess `AddItem` against `server_handle` and return the
/// assigned `ItemHandle`.
///
/// # Errors
///
/// Returns [`Error::Command`] when MXAccess rejects the item
/// definition, plus transport/status errors from tonic.
pub async fn add_item(&self, server_handle: i32, item_definition: &str) -> Result<i32, Error> {
let reply = self
.invoke(
MxCommandKind::AddItem,
Payload::AddItem(AddItemCommand {
server_handle,
item_definition: item_definition.to_owned(),
}),
)
.await?;
add_item_handle(&reply)
}
/// Run MXAccess `AddItem2` (item with a caller-supplied context string)
/// and return the assigned `ItemHandle`.
///
/// # Errors
///
/// Same conditions as [`Session::add_item`].
pub async fn add_item2(
&self,
server_handle: i32,
item_definition: &str,
item_context: &str,
) -> Result<i32, Error> {
let reply = self
.invoke(
MxCommandKind::AddItem2,
Payload::AddItem2(AddItem2Command {
server_handle,
item_definition: item_definition.to_owned(),
item_context: item_context.to_owned(),
}),
)
.await?;
add_item2_handle(&reply)
}
/// Run MXAccess `RemoveItem` for the given handle pair.
///
/// # Errors
///
/// Returns [`Error::Command`] on a non-OK worker status, plus the
/// usual transport/status errors.
pub async fn remove_item(&self, server_handle: i32, item_handle: i32) -> Result<(), Error> {
self.invoke(
MxCommandKind::RemoveItem,
Payload::RemoveItem(RemoveItemCommand {
server_handle,
item_handle,
}),
)
.await?;
Ok(())
}
/// Run MXAccess `Advise` to start receiving change notifications for
/// the given item.
///
/// # Errors
///
/// Returns [`Error::Command`] when the worker reports a non-OK status.
pub async fn advise(&self, server_handle: i32, item_handle: i32) -> Result<(), Error> {
self.invoke(
MxCommandKind::Advise,
Payload::Advise(AdviseCommand {
server_handle,
item_handle,
}),
)
.await?;
Ok(())
}
/// Run MXAccess `UnAdvise` to stop change notifications for the given
/// item.
///
/// # Errors
///
/// Returns [`Error::Command`] when the worker reports a non-OK status.
pub async fn un_advise(&self, server_handle: i32, item_handle: i32) -> Result<(), Error> {
self.invoke(
MxCommandKind::UnAdvise,
Payload::UnAdvise(UnAdviseCommand {
server_handle,
item_handle,
}),
)
.await?;
Ok(())
}
/// Run MXAccess `AdviseSupervisory` to start supervisory-mode change
/// notifications for the given item. Mirrors [`Session::advise`]; the
/// worker returns no payload, so the call resolves to `()` on success.
///
/// # Errors
///
/// Returns [`Error::Command`] for a non-OK protocol status and
/// [`Error::MxAccess`] when MXAccess reports a negative `hresult` /
/// non-success status, plus the usual transport/status errors.
pub async fn advise_supervisory(
&self,
server_handle: i32,
item_handle: i32,
) -> Result<(), Error> {
self.invoke(
MxCommandKind::AdviseSupervisory,
Payload::AdviseSupervisory(AdviseSupervisoryCommand {
server_handle,
item_handle,
}),
)
.await?;
Ok(())
}
/// Run MXAccess `Suspend` on the given item and return the native
/// `MXSTATUS_PROXY` the worker reports for the operation.
///
/// A top-level MXAccess failure (negative `hresult` or a non-success
/// top-level status) still surfaces as [`Error::MxAccess`] via the shared
/// reply validation; the returned [`MxStatus`] is the per-operation status
/// carried in the `SuspendReply` payload.
///
/// # Errors
///
/// Returns [`Error::Command`] for a non-OK protocol status,
/// [`Error::MxAccess`] on an MXAccess-level failure, and
/// [`Error::MalformedReply`] if the OK reply lacks the `Suspend` payload,
/// plus the usual transport/status errors.
pub async fn suspend(&self, server_handle: i32, item_handle: i32) -> Result<MxStatus, Error> {
let reply = self
.invoke(
MxCommandKind::Suspend,
Payload::Suspend(SuspendCommand {
server_handle,
item_handle,
}),
)
.await?;
suspend_status(reply)
}
/// Run MXAccess `Activate` on the given item and return the native
/// `MXSTATUS_PROXY` the worker reports for the operation. See
/// [`Session::suspend`] for the status/error contract.
///
/// # Errors
///
/// Same conditions as [`Session::suspend`] (with the `Activate` payload).
pub async fn activate(&self, server_handle: i32, item_handle: i32) -> Result<MxStatus, Error> {
let reply = self
.invoke(
MxCommandKind::Activate,
Payload::Activate(ActivateCommand {
server_handle,
item_handle,
}),
)
.await?;
activate_status(reply)
}
/// Run MXAccess `AddBufferedItem` against `server_handle` and return the
/// assigned `ItemHandle`. Mirrors [`Session::add_item2`] — the buffered
/// item carries a caller-supplied context string.
///
/// # Errors
///
/// Returns [`Error::Command`]/[`Error::MxAccess`] when the worker or
/// MXAccess rejects the item, [`Error::MalformedReply`] if the OK reply
/// lacks the item handle, plus the usual transport/status errors.
pub async fn add_buffered_item(
&self,
server_handle: i32,
item_definition: &str,
item_context: &str,
) -> Result<i32, Error> {
let reply = self
.invoke(
MxCommandKind::AddBufferedItem,
Payload::AddBufferedItem(AddBufferedItemCommand {
server_handle,
item_definition: item_definition.to_owned(),
item_context: item_context.to_owned(),
}),
)
.await?;
add_buffered_item_handle(&reply)
}
/// Run MXAccess `SetBufferedUpdateInterval` for `server_handle`. The
/// worker returns no payload, so the call resolves to `()` on success.
///
/// # Errors
///
/// Returns [`Error::Command`]/[`Error::MxAccess`] on a non-OK protocol
/// status or an MXAccess-level failure, plus the usual transport/status
/// errors.
pub async fn set_buffered_update_interval(
&self,
server_handle: i32,
update_interval_milliseconds: i32,
) -> Result<(), Error> {
self.invoke(
MxCommandKind::SetBufferedUpdateInterval,
Payload::SetBufferedUpdateInterval(SetBufferedUpdateIntervalCommand {
server_handle,
update_interval_milliseconds,
}),
)
.await?;
Ok(())
}
/// Bulk variant of [`Session::add_item`]. Each tag address yields one
/// `SubscribeResult` in the returned vector.
///
/// # Errors
///
/// Returns [`Error::InvalidArgument`] when the input exceeds the
/// gateway's 1000-item bulk cap, plus the usual command-level errors.
pub async fn add_item_bulk(
&self,
server_handle: i32,
tag_addresses: Vec<String>,
) -> Result<Vec<SubscribeResult>, Error> {
ensure_bulk_size("tag_addresses", tag_addresses.len())?;
let reply = self
.invoke(
MxCommandKind::AddItemBulk,
Payload::AddItemBulk(AddItemBulkCommand {
server_handle,
tag_addresses,
}),
)
.await?;
bulk_results(reply, BulkReplyKind::AddItem)
}
/// Bulk variant of [`Session::advise`].
///
/// # Errors
///
/// Same conditions as [`Session::add_item_bulk`].
pub async fn advise_item_bulk(
&self,
server_handle: i32,
item_handles: Vec<i32>,
) -> Result<Vec<SubscribeResult>, Error> {
ensure_bulk_size("item_handles", item_handles.len())?;
let reply = self
.invoke(
MxCommandKind::AdviseItemBulk,
Payload::AdviseItemBulk(AdviseItemBulkCommand {
server_handle,
item_handles,
}),
)
.await?;
bulk_results(reply, BulkReplyKind::AdviseItem)
}
/// Bulk variant of [`Session::remove_item`].
///
/// # Errors
///
/// Same conditions as [`Session::add_item_bulk`].
pub async fn remove_item_bulk(
&self,
server_handle: i32,
item_handles: Vec<i32>,
) -> Result<Vec<SubscribeResult>, Error> {
ensure_bulk_size("item_handles", item_handles.len())?;
let reply = self
.invoke(
MxCommandKind::RemoveItemBulk,
Payload::RemoveItemBulk(RemoveItemBulkCommand {
server_handle,
item_handles,
}),
)
.await?;
bulk_results(reply, BulkReplyKind::RemoveItem)
}
/// Bulk variant of [`Session::un_advise`].
///
/// # Errors
///
/// Same conditions as [`Session::add_item_bulk`].
pub async fn un_advise_item_bulk(
&self,
server_handle: i32,
item_handles: Vec<i32>,
) -> Result<Vec<SubscribeResult>, Error> {
ensure_bulk_size("item_handles", item_handles.len())?;
let reply = self
.invoke(
MxCommandKind::UnAdviseItemBulk,
Payload::UnAdviseItemBulk(UnAdviseItemBulkCommand {
server_handle,
item_handles,
}),
)
.await?;
bulk_results(reply, BulkReplyKind::UnAdviseItem)
}
/// Bulk `Subscribe` (atomic add-and-advise) for a list of tag addresses.
///
/// # Errors
///
/// Same conditions as [`Session::add_item_bulk`].
pub async fn subscribe_bulk(
&self,
server_handle: i32,
tag_addresses: Vec<String>,
) -> Result<Vec<SubscribeResult>, Error> {
ensure_bulk_size("tag_addresses", tag_addresses.len())?;
let reply = self
.invoke(
MxCommandKind::SubscribeBulk,
Payload::SubscribeBulk(SubscribeBulkCommand {
server_handle,
tag_addresses,
}),
)
.await?;
bulk_results(reply, BulkReplyKind::Subscribe)
}
/// Bulk `Unsubscribe` (atomic un-advise-and-remove) for a list of
/// item handles.
///
/// # Errors
///
/// Same conditions as [`Session::add_item_bulk`].
pub async fn unsubscribe_bulk(
&self,
server_handle: i32,
item_handles: Vec<i32>,
) -> Result<Vec<SubscribeResult>, Error> {
ensure_bulk_size("item_handles", item_handles.len())?;
let reply = self
.invoke(
MxCommandKind::UnsubscribeBulk,
Payload::UnsubscribeBulk(UnsubscribeBulkCommand {
server_handle,
item_handles,
}),
)
.await?;
bulk_results(reply, BulkReplyKind::Unsubscribe)
}
/// Bulk `Read` — snapshot the current value for each requested tag.
///
/// MXAccess COM has no synchronous `Read`; the worker satisfies this by
/// returning the most recent cached `OnDataChange` value when the tag is
/// already advised (`was_cached = true`), or by taking a full AddItem +
/// Advise + wait + UnAdvise + RemoveItem snapshot lifecycle otherwise.
/// `timeout_ms` is one shared deadline for the whole command, not a
/// per-tag budget: the worker advises every uncached tag, waits once for
/// all of them, then tears them down. `timeout_ms == 0` lets the worker
/// pick its default (1000 ms).
/// Per-tag failures appear as `BulkReadResult` entries with
/// `was_successful = false`; the call never errors on per-tag failure.
///
/// # Errors
///
/// Same conditions as [`Session::add_item_bulk`].
pub async fn read_bulk<S: AsRef<str>>(
&self,
server_handle: i32,
tag_addresses: &[S],
timeout_ms: u32,
) -> Result<Vec<BulkReadResult>, Error> {
ensure_bulk_size("tag_addresses", tag_addresses.len())?;
let reply = self
.invoke(
MxCommandKind::ReadBulk,
Payload::ReadBulk(ReadBulkCommand {
server_handle,
tag_addresses: tag_addresses
.iter()
.map(|tag| tag.as_ref().to_owned())
.collect(),
timeout_ms,
}),
)
.await?;
match reply.payload {
Some(mx_command_reply::Payload::ReadBulk(reply)) => Ok(reply.results),
_ => Err(Error::MalformedReply {
detail: "read_bulk reply did not carry a ReadBulk payload".to_owned(),
}),
}
}
/// Bulk `Write` (sequential MXAccess Write per entry, on the worker's STA).
///
/// Per-entry MXAccess failures are reported as `BulkWriteResult` entries
/// with `was_successful = false`; the call never errors on per-entry
/// failure. Protocol-level failures still surface as [`Error::Command`].
///
/// # Errors
///
/// Same conditions as [`Session::add_item_bulk`], plus the usual
/// transport/status errors.
pub async fn write_bulk(
&self,
server_handle: i32,
entries: Vec<WriteBulkEntry>,
) -> Result<Vec<BulkWriteResult>, Error> {
ensure_bulk_size("entries", entries.len())?;
let reply = self
.invoke(
MxCommandKind::WriteBulk,
Payload::WriteBulk(WriteBulkCommand {
server_handle,
entries,
}),
)
.await?;
bulk_write_results(reply, BulkWriteReplyKind::Write)
}
/// Bulk `Write2` (timestamped) — see [`Session::write_bulk`].
///
/// # Errors
///
/// Same conditions as [`Session::write_bulk`].
pub async fn write2_bulk(
&self,
server_handle: i32,
entries: Vec<Write2BulkEntry>,
) -> Result<Vec<BulkWriteResult>, Error> {
ensure_bulk_size("entries", entries.len())?;
let reply = self
.invoke(
MxCommandKind::Write2Bulk,
Payload::Write2Bulk(Write2BulkCommand {
server_handle,
entries,
}),
)
.await?;
bulk_write_results(reply, BulkWriteReplyKind::Write2)
}
/// Bulk `WriteSecured` — credential-sensitive values follow the same
/// redaction contract as the single-item `write_secured` path.
///
/// # Errors
///
/// Same conditions as [`Session::write_bulk`].
pub async fn write_secured_bulk(
&self,
server_handle: i32,
entries: Vec<WriteSecuredBulkEntry>,
) -> Result<Vec<BulkWriteResult>, Error> {
ensure_bulk_size("entries", entries.len())?;
let reply = self
.invoke(
MxCommandKind::WriteSecuredBulk,
Payload::WriteSecuredBulk(WriteSecuredBulkCommand {
server_handle,
entries,
}),
)
.await?;
bulk_write_results(reply, BulkWriteReplyKind::WriteSecured)
}
/// Bulk `WriteSecured2` (timestamped) — see [`Session::write_secured_bulk`].
///
/// # Errors
///
/// Same conditions as [`Session::write_bulk`].
pub async fn write_secured2_bulk(
&self,
server_handle: i32,
entries: Vec<WriteSecured2BulkEntry>,
) -> Result<Vec<BulkWriteResult>, Error> {
ensure_bulk_size("entries", entries.len())?;
let reply = self
.invoke(
MxCommandKind::WriteSecured2Bulk,
Payload::WriteSecured2Bulk(WriteSecured2BulkCommand {
server_handle,
entries,
}),
)
.await?;
bulk_write_results(reply, BulkWriteReplyKind::WriteSecured2)
}
/// Run MXAccess `Write` (single-value, no caller-supplied timestamp).
///
/// # Errors
///
/// Returns [`Error::Command`] for non-OK worker statuses, plus the
/// usual transport/status errors.
pub async fn write(
&self,
server_handle: i32,
item_handle: i32,
value: MxValue,
user_id: i32,
) -> Result<(), Error> {
self.invoke(
MxCommandKind::Write,
Payload::Write(WriteCommand {
server_handle,
item_handle,
value: Some(value.into_proto()),
user_id,
}),
)
.await?;
Ok(())
}
/// Write a sparse, default-filled array: only the given elements
/// (index → scalar value) are set; every unmentioned index up to
/// `total_length` is written as the element type's default (a reset,
/// **not** a preserve). The gateway expands the sparse representation into
/// a whole-array write before forwarding to the worker.
///
/// This is a convenience wrapper around [`Session::write`] that builds the
/// `MxSparseArray` wire value for you. Call [`Session::write`] directly
/// if you need to pass a pre-built [`MxValue`] carrying a full
/// `MxArray`.
///
/// # Errors
///
/// Returns [`Error::InvalidArgument`] (propagated from the gateway) if
/// `total_length` is zero, exceeds the gateway's maximum array length, or
/// any element index is out of range. Returns [`Error::Command`] for
/// non-OK worker statuses, plus the usual transport/status errors.
pub async fn write_array_elements(
&self,
server_handle: i32,
item_handle: i32,
element_data_type: MxDataType,
total_length: u32,
elements: impl IntoIterator<Item = (u32, MxValue)>,
user_id: i32,
) -> Result<(), Error> {
use crate::generated::mxaccess_gateway::v1::mx_value::Kind;
let sparse_elements: Vec<MxSparseElement> = elements
.into_iter()
.map(|(index, value)| MxSparseElement {
index,
value: Some(value.into_proto()),
})
.collect();
let sparse_value = ProtoMxValue {
kind: Some(Kind::SparseArrayValue(MxSparseArray {
element_data_type: element_data_type as i32,
total_length,
elements: sparse_elements,
})),
..ProtoMxValue::default()
};
self.write(
server_handle,
item_handle,
MxValue::from_proto(sparse_value),
user_id,
)
.await
}
/// Run MXAccess `Write2` (single-value with caller-supplied timestamp).
///
/// # Errors
///
/// Same conditions as [`Session::write`].
pub async fn write2(
&self,
server_handle: i32,
item_handle: i32,
value: MxValue,
timestamp_value: MxValue,
user_id: i32,
) -> Result<(), Error> {
self.invoke(
MxCommandKind::Write2,
Payload::Write2(Write2Command {
server_handle,
item_handle,
value: Some(value.into_proto()),
timestamp_value: Some(timestamp_value.into_proto()),
user_id,
}),
)
.await?;
Ok(())
}
/// Run MXAccess `WriteSecured` (single credential-verified write, no
/// caller-supplied timestamp).
///
/// **MXAccess parity:** `WriteSecured` failing before a prior
/// [`Session::authenticate_user`] + [`Session::advise_supervisory`], or
/// before a value-bearing body, is the native contract, not a client bug —
/// the failure surfaces as [`Error::MxAccess`] (negative `hresult`) and is
/// **not** smoothed over. The `value` is credential-sensitive: it is placed
/// only in the wire command and is never logged or embedded in an error
/// message.
///
/// # Errors
///
/// Returns [`Error::Command`] for a non-OK protocol status,
/// [`Error::MxAccess`] when MXAccess rejects the secured write, plus the
/// usual transport/status errors.
pub async fn write_secured(
&self,
server_handle: i32,
item_handle: i32,
current_user_id: i32,
verifier_user_id: i32,
value: MxValue,
) -> Result<(), Error> {
let secrets = string_secret(&value);
self.invoke(
MxCommandKind::WriteSecured,
Payload::WriteSecured(WriteSecuredCommand {
server_handle,
item_handle,
current_user_id,
verifier_user_id,
value: Some(value.into_proto()),
}),
)
.await
.map_err(|error| attach_secrets(error, secrets))?;
Ok(())
}
/// Run MXAccess `WriteSecured2` (credential-verified write with a
/// caller-supplied timestamp). See [`Session::write_secured`] for the
/// parity and credential-handling contract.
///
/// # Errors
///
/// Same conditions as [`Session::write_secured`].
pub async fn write_secured2(
&self,
server_handle: i32,
item_handle: i32,
current_user_id: i32,
verifier_user_id: i32,
value: MxValue,
timestamp_value: MxValue,
) -> Result<(), Error> {
let secrets = string_secret(&value);
self.invoke(
MxCommandKind::WriteSecured2,
Payload::WriteSecured2(WriteSecured2Command {
server_handle,
item_handle,
current_user_id,
verifier_user_id,
value: Some(value.into_proto()),
timestamp_value: Some(timestamp_value.into_proto()),
}),
)
.await
.map_err(|error| attach_secrets(error, secrets))?;
Ok(())
}
/// Run MXAccess `AuthenticateUser` and return the resolved MXAccess user
/// id.
///
/// **Credential handling:** `verify_user_password` is a raw MXAccess
/// credential. It is placed only in the wire command; this helper never
/// logs it and never embeds it in an [`Error`] — the only error text that
/// can surface comes from `tonic::Status` messages and the reply's
/// diagnostic fields, both of which are scrubbed by the client's
/// credential-redaction seam (see [`crate::error`]). The reply itself
/// carries no echo of the credential.
///
/// **MXAccess parity:** a failed authentication is a native outcome, not a
/// client error to paper over — it surfaces as [`Error::MxAccess`]
/// (negative `hresult`) with the credential absent from the message.
///
/// # Errors
///
/// Returns [`Error::Command`] for a non-OK protocol status,
/// [`Error::MxAccess`] when MXAccess rejects the credential,
/// [`Error::MalformedReply`] if the OK reply lacks the user id, plus the
/// usual transport/status errors.
pub async fn authenticate_user(
&self,
server_handle: i32,
verify_user: &str,
verify_user_password: &str,
) -> Result<i32, Error> {
let reply = self
.invoke(
MxCommandKind::AuthenticateUser,
Payload::AuthenticateUser(AuthenticateUserCommand {
server_handle,
verify_user: verify_user.to_owned(),
verify_user_password: verify_user_password.to_owned(),
}),
)
.await
.map_err(|error| attach_secrets(error, vec![verify_user_password.to_owned()]))?;
authenticate_user_id(&reply)
}
/// Run MXAccess `ArchestrAUserToId` to resolve an ArchestrA user GUID to
/// its MXAccess user id.
///
/// # Errors
///
/// Returns [`Error::Command`]/[`Error::MxAccess`] on a non-OK protocol
/// status or MXAccess-level failure, [`Error::MalformedReply`] if the OK
/// reply lacks the user id, plus the usual transport/status errors.
pub async fn archestra_user_to_id(
&self,
server_handle: i32,
user_id_guid: &str,
) -> Result<i32, Error> {
let reply = self
.invoke(
MxCommandKind::ArchestraUserToId,
Payload::ArchestraUserToId(ArchestrAUserToIdCommand {
server_handle,
user_id_guid: user_id_guid.to_owned(),
}),
)
.await?;
archestra_user_id(&reply)
}
/// Open the per-session event stream from the beginning.
///
/// The returned [`EventStream`] yields [`EventItem`](crate::EventItem)
/// values — normal MXAccess events as
/// [`EventItem::Event`](crate::EventItem::Event). A stream opened from the
/// beginning never produces a
/// [`EventItem::ReplayGap`](crate::EventItem::ReplayGap); that sentinel
/// only appears on a resumed stream (see [`Session::events_after`]).
///
/// # Errors
///
/// Returns the `tonic::Status` mapped through [`Error::from`] when the
/// gateway rejects the subscription.
pub async fn events(&self) -> Result<EventStream, Error> {
self.events_after(0).await
}
/// Open the per-session event stream, requesting only events whose
/// `worker_sequence` is greater than `after_worker_sequence`. Pass `0`
/// to receive every buffered event.
///
/// If `after_worker_sequence` predates the oldest event still retained in
/// the gateway's replay ring, the stream opens with a single
/// [`EventItem::ReplayGap`](crate::EventItem::ReplayGap) sentinel: recent
/// history was evicted and cannot be replayed, so the caller must discard
/// any local state and re-snapshot. To resume without provoking another
/// gap, call this method again with
/// `gap.oldest_available_sequence - 1`. See
/// [`EventItem`](crate::EventItem) for the full contract.
///
/// # Errors
///
/// Same conditions as [`Session::events`].
pub async fn events_after(&self, after_worker_sequence: u64) -> Result<EventStream, Error> {
self.client
.stream_events(StreamEventsRequest {
session_id: self.id.clone(),
after_worker_sequence,
})
.await
}
/// Send a `Ping` diagnostic command to the worker. A successful return
/// means the worker answered; `message` is forwarded for log correlation.
///
/// # Errors
///
/// Returns [`Error::Command`] for non-OK statuses plus transport errors.
pub async fn ping(&self, message: &str) -> Result<(), Error> {
self.invoke(
MxCommandKind::Ping,
Payload::Ping(PingCommand {
message: message.to_owned(),
}),
)
.await?;
Ok(())
}
/// Fetch the gateway-side [`SessionState`] of this session.
///
/// # Errors
///
/// Returns [`Error::MalformedReply`] when the reply lacks a session-state
/// payload, plus the errors of [`invoke`](Self::invoke).
pub async fn get_session_state(&self) -> Result<SessionState, Error> {
let reply = self
.invoke(
MxCommandKind::GetSessionState,
Payload::GetSessionState(GetSessionStateCommand {}),
)
.await?;
match reply.payload {
Some(mx_command_reply::Payload::SessionState(state)) => {
Ok(SessionState::try_from(state.state).unwrap_or(SessionState::Unspecified))
}
_ => Err(Error::MalformedReply {
detail: "get_session_state reply did not carry a SessionState payload".to_owned(),
}),
}
}
/// Fetch worker process details (pid, version, MXAccess ProgID/CLSID).
///
/// # Errors
///
/// Returns [`Error::MalformedReply`] when the reply lacks a worker-info
/// payload, plus the errors of [`invoke`](Self::invoke).
pub async fn get_worker_info(&self) -> Result<WorkerInfoReply, Error> {
let reply = self
.invoke(
MxCommandKind::GetWorkerInfo,
Payload::GetWorkerInfo(GetWorkerInfoCommand {}),
)
.await?;
match reply.payload {
Some(mx_command_reply::Payload::WorkerInfo(info)) => Ok(info),
_ => Err(Error::MalformedReply {
detail: "get_worker_info reply did not carry a WorkerInfo payload".to_owned(),
}),
}
}
/// Drain up to `max_events` queued events through the diagnostic command
/// (distinct from the streaming [`events`](Self::events) surface). The
/// reply is bounded server-side, so call repeatedly until it is empty.
///
/// # Errors
///
/// Returns [`Error::MalformedReply`] when the reply lacks a drain-events
/// payload, plus the errors of [`invoke`](Self::invoke).
pub async fn drain_events(&self, max_events: u32) -> Result<Vec<MxEvent>, Error> {
let reply = self
.invoke(
MxCommandKind::DrainEvents,
Payload::DrainEvents(DrainEventsCommand { max_events }),
)
.await?;
match reply.payload {
Some(mx_command_reply::Payload::DrainEvents(drain)) => Ok(drain.events),
_ => Err(Error::MalformedReply {
detail: "drain_events reply did not carry a DrainEvents payload".to_owned(),
}),
}
}
/// Issue a raw `Invoke` for an arbitrary command, without filtering on
/// the protocol status. Useful when callers need the full reply for
/// commands not yet wrapped by `Session`.
///
/// # Errors
///
/// Returns the `tonic::Status` mapped through [`Error::from`].
pub async fn invoke_raw(
&self,
kind: MxCommandKind,
payload: Payload,
) -> Result<MxCommandReply, Error> {
self.client
.invoke_raw(self.command_request(kind, payload))
.await
}
/// Issue an `Invoke` for an arbitrary command and surface a non-OK
/// reply as [`Error::Command`].
///
/// # Errors
///
/// Returns [`Error::Command`] for non-OK worker statuses plus any
/// errors propagated by [`invoke_raw`](Self::invoke_raw).
pub async fn invoke(
&self,
kind: MxCommandKind,
payload: Payload,
) -> Result<MxCommandReply, Error> {
self.client
.invoke(self.command_request(kind, payload))
.await
}
fn command_request(&self, kind: MxCommandKind, payload: Payload) -> MxCommandRequest {
MxCommandRequest {
session_id: self.id.clone(),
client_correlation_id: next_correlation_id(kind.as_str_name()),
command: Some(MxCommand {
kind: kind as i32,
payload: Some(payload),
}),
}
}
}
fn ensure_bulk_size(name: &'static str, len: usize) -> Result<(), Error> {
if len > MAX_BULK_ITEMS {
Err(Error::InvalidArgument {
name: name.to_owned(),
detail: format!("bulk commands are limited to {MAX_BULK_ITEMS} item(s)"),
})
} else {
Ok(())
}
}
fn register_server_handle(reply: &MxCommandReply) -> Result<i32, Error> {
match reply.payload.as_ref() {
Some(mx_command_reply::Payload::Register(register)) => Ok(register.server_handle),
_ => reply
.return_value
.as_ref()
.and_then(int32_reply_value)
.ok_or_else(|| Error::MalformedReply {
detail: "register reply lacked a server_handle payload or int32 return_value"
.to_owned(),
}),
}
}
fn add_item_handle(reply: &MxCommandReply) -> Result<i32, Error> {
match reply.payload.as_ref() {
Some(mx_command_reply::Payload::AddItem(add_item)) => Ok(add_item.item_handle),
_ => reply
.return_value
.as_ref()
.and_then(int32_reply_value)
.ok_or_else(|| Error::MalformedReply {
detail: "add_item reply lacked an item_handle payload or int32 return_value"
.to_owned(),
}),
}
}
fn add_item2_handle(reply: &MxCommandReply) -> Result<i32, Error> {
match reply.payload.as_ref() {
Some(mx_command_reply::Payload::AddItem2(add_item)) => Ok(add_item.item_handle),
_ => reply
.return_value
.as_ref()
.and_then(int32_reply_value)
.ok_or_else(|| Error::MalformedReply {
detail: "add_item2 reply lacked an item_handle payload or int32 return_value"
.to_owned(),
}),
}
}
fn add_buffered_item_handle(reply: &MxCommandReply) -> Result<i32, Error> {
match reply.payload.as_ref() {
Some(mx_command_reply::Payload::AddBufferedItem(add_buffered)) => {
Ok(add_buffered.item_handle)
}
_ => reply
.return_value
.as_ref()
.and_then(int32_reply_value)
.ok_or_else(|| Error::MalformedReply {
detail:
"add_buffered_item reply lacked an item_handle payload or int32 return_value"
.to_owned(),
}),
}
}
fn authenticate_user_id(reply: &MxCommandReply) -> Result<i32, Error> {
match reply.payload.as_ref() {
Some(mx_command_reply::Payload::AuthenticateUser(authenticate)) => Ok(authenticate.user_id),
_ => reply
.return_value
.as_ref()
.and_then(int32_reply_value)
.ok_or_else(|| Error::MalformedReply {
detail:
"authenticate_user reply lacked an AuthenticateUser payload or int32 return_value"
.to_owned(),
}),
}
}
fn archestra_user_id(reply: &MxCommandReply) -> Result<i32, Error> {
match reply.payload.as_ref() {
Some(mx_command_reply::Payload::ArchestraUserToId(archestra)) => Ok(archestra.user_id),
_ => reply
.return_value
.as_ref()
.and_then(int32_reply_value)
.ok_or_else(|| Error::MalformedReply {
detail:
"archestra_user_to_id reply lacked an ArchestraUserToId payload or int32 return_value"
.to_owned(),
}),
}
}
/// Extract an exact string secret from a credential-sensitive [`MxValue`] so a
/// failing `WriteSecured`/`WriteSecured2` can scrub it from the surfaced error.
/// Non-string values carry no scrubbable secret and yield an empty vector.
fn string_secret(value: &MxValue) -> Vec<String> {
match value.projection() {
MxValueProjection::String(text) if !text.is_empty() => vec![text.clone()],
_ => Vec::new(),
}
}
/// Attach caller-supplied exact secrets to an [`Error::MxAccess`] before it
/// propagates. This both scrubs the stored reply's caller-readable string
/// fields (so `reply()`/`into_reply()` cannot recover a credential MXAccess
/// echoed back verbatim) and keeps the secrets on the error as a
/// belt-and-suspenders for `Display`/`Debug`. Any other error variant is
/// returned unchanged.
fn attach_secrets(error: Error, secrets: Vec<String>) -> Error {
match error {
Error::MxAccess(boxed) => {
let mut reply = boxed.into_reply();
scrub_reply_strings(&mut reply, &secrets);
Error::MxAccess(Box::new(MxAccessError::new(reply).with_secrets(secrets)))
}
other => other,
}
}
/// Replace every non-empty secret occurrence with `<redacted>` in the reply's
/// caller-readable string fields — `protocol_status.message`,
/// `diagnostic_message`, and each `statuses[i].diagnostic_text`. A caller
/// reading the structured reply back off an [`Error::MxAccess`] would otherwise
/// reintroduce the leak that `Display`/`Debug` already close.
fn scrub_reply_strings(reply: &mut MxCommandReply, secrets: &[String]) {
for secret in secrets {
if secret.is_empty() {
continue;
}
if let Some(status) = reply.protocol_status.as_mut() {
status.message = status.message.replace(secret.as_str(), "<redacted>");
}
reply.diagnostic_message = reply
.diagnostic_message
.replace(secret.as_str(), "<redacted>");
for status in &mut reply.statuses {
status.diagnostic_text = status
.diagnostic_text
.replace(secret.as_str(), "<redacted>");
}
}
}
fn suspend_status(reply: MxCommandReply) -> Result<MxStatus, Error> {
match reply.payload {
Some(mx_command_reply::Payload::Suspend(suspend)) => suspend
.status
.map(MxStatus::from_proto)
.ok_or_else(|| Error::MalformedReply {
detail: "suspend reply payload lacked a status entry".to_owned(),
}),
_ => Err(Error::MalformedReply {
detail: "suspend reply did not carry a Suspend payload".to_owned(),
}),
}
}
fn activate_status(reply: MxCommandReply) -> Result<MxStatus, Error> {
match reply.payload {
Some(mx_command_reply::Payload::Activate(activate)) => activate
.status
.map(MxStatus::from_proto)
.ok_or_else(|| Error::MalformedReply {
detail: "activate reply payload lacked a status entry".to_owned(),
}),
_ => Err(Error::MalformedReply {
detail: "activate reply did not carry an Activate payload".to_owned(),
}),
}
}
enum BulkReplyKind {
AddItem,
AdviseItem,
RemoveItem,
UnAdviseItem,
Subscribe,
Unsubscribe,
}
fn bulk_results(reply: MxCommandReply, kind: BulkReplyKind) -> Result<Vec<SubscribeResult>, Error> {
match (reply.payload, kind) {
(Some(mx_command_reply::Payload::AddItemBulk(reply)), BulkReplyKind::AddItem) => {
Ok(reply.results)
}
(Some(mx_command_reply::Payload::AdviseItemBulk(reply)), BulkReplyKind::AdviseItem) => {
Ok(reply.results)
}
(Some(mx_command_reply::Payload::RemoveItemBulk(reply)), BulkReplyKind::RemoveItem) => {
Ok(reply.results)
}
(Some(mx_command_reply::Payload::UnAdviseItemBulk(reply)), BulkReplyKind::UnAdviseItem) => {
Ok(reply.results)
}
(Some(mx_command_reply::Payload::SubscribeBulk(reply)), BulkReplyKind::Subscribe) => {
Ok(reply.results)
}
(Some(mx_command_reply::Payload::UnsubscribeBulk(reply)), BulkReplyKind::Unsubscribe) => {
Ok(reply.results)
}
_ => Err(Error::MalformedReply {
detail: "bulk subscribe reply did not carry the expected payload arm".to_owned(),
}),
}
}
enum BulkWriteReplyKind {
Write,
Write2,
WriteSecured,
WriteSecured2,
}
fn bulk_write_results(
reply: MxCommandReply,
kind: BulkWriteReplyKind,
) -> Result<Vec<BulkWriteResult>, Error> {
match (reply.payload, kind) {
(Some(mx_command_reply::Payload::WriteBulk(reply)), BulkWriteReplyKind::Write) => {
Ok(reply.results)
}
(Some(mx_command_reply::Payload::Write2Bulk(reply)), BulkWriteReplyKind::Write2) => {
Ok(reply.results)
}
(
Some(mx_command_reply::Payload::WriteSecuredBulk(reply)),
BulkWriteReplyKind::WriteSecured,
) => Ok(reply.results),
(
Some(mx_command_reply::Payload::WriteSecured2Bulk(reply)),
BulkWriteReplyKind::WriteSecured2,
) => Ok(reply.results),
_ => Err(Error::MalformedReply {
detail: "bulk write reply did not carry the expected payload arm".to_owned(),
}),
}
}
fn int32_reply_value(value: &ProtoMxValue) -> Option<i32> {
match value.kind.as_ref()? {
crate::generated::mxaccess_gateway::v1::mx_value::Kind::Int32Value(value) => Some(*value),
_ => None,
}
}