Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion crates/ika-sui-client/src/grpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ use tonic::metadata::{Ascii, MetadataKey, MetadataValue};
use crate::rate_limit::RateLimitGate;
use crate::transport::{
CheckpointSummaryStream, DynamicFieldEntry, DynamicFieldPage, ExecutedTransaction,
SubmittedTransaction, SuiFundsBreakdown, SuiTransport, SuiWriter, TransportError,
SubmittedTransaction, SuiFundsBreakdown, SuiNodeInfo, SuiTransport, SuiWriter, TransportError,
};

/// Sui rejects a transaction whose gas payment names more than
Expand Down Expand Up @@ -788,6 +788,27 @@ impl SuiTransport for SuiGrpcClient {
.map(|chain_identifier| chain_identifier.to_string())
}

async fn get_sui_node_info(&self) -> Result<Option<SuiNodeInfo>, TransportError> {
// Same `GetServiceInfo` call `get_latest_checkpoint_sequence` and
// `get_sui_chain_identifier` already use; we read two different
// fields off it. It takes no arguments and touches no store beyond
// the node's own watermarks, so it is the cheapest call the fullnode
// serves — which is what makes it safe to poll on a timer.
let mut rpc = self.rpc.clone();
let response = self
.gated_network(async move {
rpc.ledger_client()
.get_service_info(proto::GetServiceInfoRequest::default())
.await
})
.await?
.into_inner();
Ok(Some(SuiNodeInfo {
server_version: response.server_opt().map(str::to_owned),
chain_identifier: response.chain_id_opt().map(str::to_owned),
}))
}

async fn get_current_epoch(&self) -> Result<u64, TransportError> {
let mut rpc = self.rpc.clone();
let mut request = proto::GetEpochRequest::default();
Expand Down
9 changes: 9 additions & 0 deletions crates/ika-sui-client/src/grpc_backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,15 @@ impl GrpcSuiClient {
}
}

/// The read transport this backend was built over. Handed to the
/// `ika_sui_client_sui_node_info` refresher, which needs a `'static`
/// handle to the same uplink the client reads through — so that the
/// version it reports is the version of the node actually serving this
/// client, not of some separately-opened connection.
pub fn transport(&self) -> std::sync::Arc<dyn SuiTransport> {
self.transport.clone()
}

/// The writer uplink, or a clear error on a read-only node. Transaction
/// building/submission (and the migration-sweep reads that serve it) are
/// notifier-gated to a direct fullnode connection.
Expand Down
22 changes: 22 additions & 0 deletions crates/ika-sui-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ pub mod ika_dwallet_transactions;
pub mod ika_protocol_transactions;
pub mod ika_validator_transactions;
pub mod metrics;
pub mod node_info;
pub mod rate_limit;
pub mod transaction_builder;
pub mod transaction_context;
Expand Down Expand Up @@ -102,6 +103,11 @@ pub struct SuiClient<P> {
system_arg_cache: OnceCell<ObjectArg>,
clock_arg_cache: OnceCell<ObjectArg>,
dwallet_coordinator_arg_cache: OnceCell<ObjectArg>,
/// Keeps `ika_sui_client_sui_node_info` current for as long as this client
/// exists — the handle aborts the refresh task on drop, so a short-lived
/// CLI client does not leave a timer behind. `None` on clients built for
/// tests, which have no registry anyone scrapes.
_node_info_refresh: Option<node_info::SuiNodeInfoRefresh>,
}

pub type SuiBackend = grpc_backend::GrpcSuiClient;
Expand Down Expand Up @@ -144,13 +150,19 @@ impl SuiConnectorClient {
let inner =
grpc_backend::GrpcSuiClient::new_with_headers(grpc_url, headers, rate_limit_gate)
.await?;
// Spawned, never awaited: identifying the fullnode must not add a
// round trip to node boot, least of all on the wedged-uplink nodes
// this metric exists to describe.
let node_info_refresh =
node_info::spawn_sui_node_info_refresh(inner.transport(), sui_client_metrics.clone());
let self_ = Self {
inner,
sui_client_metrics,
ika_network_config,
system_arg_cache: OnceCell::new(),
clock_arg_cache: OnceCell::new(),
dwallet_coordinator_arg_cache: OnceCell::new(),
_node_info_refresh: Some(node_info_refresh),
};
self_.describe().await?;
Ok(self_)
Expand All @@ -166,13 +178,19 @@ impl SuiConnectorClient {
ika_network_config: IkaNetworkConfig,
) -> anyhow::Result<Self> {
let inner = grpc_backend::GrpcSuiClient::with_transport(transport);
// The relay transport has no service-info RPC, so this settles on
// `server_version="unsupported"` after one probe and stops — which is
// the honest answer for a node with no fullnode of its own.
let node_info_refresh =
node_info::spawn_sui_node_info_refresh(inner.transport(), sui_client_metrics.clone());
let self_ = Self {
inner,
sui_client_metrics,
ika_network_config,
system_arg_cache: OnceCell::new(),
clock_arg_cache: OnceCell::new(),
dwallet_coordinator_arg_cache: OnceCell::new(),
_node_info_refresh: Some(node_info_refresh),
};
self_.describe().await?;
Ok(self_)
Expand Down Expand Up @@ -218,6 +236,10 @@ where
system_arg_cache: OnceCell::new(),
clock_arg_cache: OnceCell::new(),
dwallet_coordinator_arg_cache: OnceCell::new(),
// Not spawned for tests: `new_for_testing` is called from
// synchronous contexts with no runtime, and nothing scrapes the
// throwaway registry it builds.
_node_info_refresh: None,
}
}

Expand Down
155 changes: 153 additions & 2 deletions crates/ika-sui-client/src/metrics.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,29 @@
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: BSD-3-Clause-Clear

use prometheus::{IntCounterVec, Registry, register_int_counter_vec_with_registry};
use std::sync::Arc;
use prometheus::{
IntCounterVec, IntGauge, IntGaugeVec, Registry, register_int_counter_vec_with_registry,
register_int_gauge_vec_with_registry, register_int_gauge_with_registry,
};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};

/// Label value published on [`SuiClientMetrics::sui_node_info`] from process
/// start until the node's `GetServiceInfo` first answers. A validator whose
/// series is *still* on this label is the signal: its Sui uplink has never
/// once told us what it is.
pub const SUI_NODE_INFO_UNKNOWN: &str = "unknown";

/// Label value for a client whose transport has no service-info RPC at all —
/// a peer-only validator reading Sui through the Ika p2p mirror relay. There
/// is no operator fullnode on the other end to have a version, so this is a
/// *healthy* terminal state and must not be read as `unknown`.
pub const SUI_NODE_INFO_UNSUPPORTED: &str = "unsupported";

/// Label value for a node that answered `GetServiceInfo` but left the field
/// empty (both fields are `optional` in the proto, and a proxy in front of a
/// fullnode can strip them). Distinct from `unknown`: the RPC works.
pub const SUI_NODE_INFO_UNREPORTED: &str = "unreported";

/// Process-wide counter for chain-side calls to
/// `get_network_encryption_key_with_full_data_by_epoch`. Test
Expand Down Expand Up @@ -50,6 +70,60 @@ pub struct SuiClientMetrics {
/// keeps the endpoint refusing. Fleet-wide this should be flat at zero;
/// growth on a single node points at that node's endpoint, not at ika.
pub sui_rate_limited_errors: IntCounterVec,
/// Info-style gauge (always `1` on exactly one child) identifying the Sui
/// fullnode this validator's uplink is pointed at: `server_version` is the
/// node's own software version string, `chain_identifier` its genesis
/// digest.
///
/// # Why
///
/// Two mainnet validators wedged in early boot at consecutive epoch
/// boundaries (2026-08-28 and 08-29). Both had their own Sui fullnode's
/// RPC failing for hours beforehand — one at ~440 errors/hour, the other
/// ~316, against a fleet baseline of 0–6. The leading hypothesis for the
/// first is simply that the operator never upgraded their Sui node across
/// a Sui release rollout. We could not confirm or refute that, because
/// nothing anywhere in the fleet's telemetry says which Sui version a
/// validator is talking to: `sui_rpc_errors` tells us an uplink is
/// unhealthy, never *what* is on the other end of it. This gauge closes
/// that gap — one `group by (server_version)` over the fleet shows the
/// version spread and singles out the operator who is behind.
///
/// # Semantics
///
/// Registered eagerly with `server_version="unknown"` at `1`, so a node
/// that never gets an answer still publishes a series — a validator stuck
/// on `unknown` is itself the signal that its Sui RPC never replied.
/// Refreshes never *remove* a child, they flip the old one to `0` and the
/// new one to `1`, so a version change reads as a clean 1→0 / 0→1
/// transition across a scrape gap instead of a disappearing series.
///
/// # Cardinality
///
/// One child at `1` per node at a time, plus one retired child at `0` per
/// version the node has ever reported — in practice one or two per
/// process lifetime, since an operator's Sui version changes only when
/// they upgrade and the process restarts on ours. Negligible.
pub sui_node_info: IntGaugeVec,
/// Unix seconds of the last successful `GetServiceInfo`; `0` until the
/// first one lands.
///
/// Freshness companion to [`Self::sui_node_info`], which on its own cannot
/// distinguish "this really is the version, confirmed minutes ago" from
/// "this was the version hours ago and the RPC has been dead since" — the
/// info gauge holds its last known value by design. `0` additionally
/// separates "never answered" from "answered once and went stale", which
/// matters because a boot-time wedge produces the former and a mid-life
/// failure the latter.
///
/// Left at `0` forever on a peer-only node (see
/// [`SUI_NODE_INFO_UNSUPPORTED`]) — there is nothing to refresh, so pair
/// any staleness query with `server_version != "unsupported"`.
pub sui_node_info_last_success_unixtime: IntGauge,
/// The `(server_version, chain_identifier)` pair currently published at
/// `1`. Held so a refresh can flip exactly the previous child to `0`
/// without enumerating (or resetting) the vec.
active_sui_node_info: Arc<Mutex<[String; 2]>>,
}

impl SuiClientMetrics {
Expand All @@ -76,6 +150,23 @@ impl SuiClientMetrics {
registry,
)
.unwrap(),
sui_node_info: register_int_gauge_vec_with_registry!(
"ika_sui_client_sui_node_info",
"Info gauge (always 1) identifying the Sui fullnode this validator's uplink is connected to, by its reported server version and chain id",
&["server_version", "chain_identifier"],
registry,
)
.unwrap(),
sui_node_info_last_success_unixtime: register_int_gauge_with_registry!(
"ika_sui_client_sui_node_info_last_success_unixtime",
"Unix seconds of the last successful Sui GetServiceInfo call; 0 until the first one succeeds",
registry,
)
.unwrap(),
active_sui_node_info: Arc::new(Mutex::new([
SUI_NODE_INFO_UNKNOWN.to_string(),
SUI_NODE_INFO_UNKNOWN.to_string(),
])),
};
// Publish both series from process start so a dashboard/alert can tell
// "no rate limiting" apart from "this node never reported".
Expand All @@ -87,11 +178,71 @@ impl SuiClientMetrics {
.with_label_values(&[signal.label()])
.inc_by(0);
}
// Same reasoning, one step stronger: the `unknown` child is not just a
// zero placeholder, it is the value that stays at 1 for a node whose
// Sui RPC never answers. It has to exist before the first refresh —
// which for a wedged node is a refresh that never completes.
this.sui_node_info
.with_label_values(&[SUI_NODE_INFO_UNKNOWN, SUI_NODE_INFO_UNKNOWN])
.set(1);
this.sui_node_info_last_success_unixtime.set(0);
Arc::new(this)
}

pub fn new_for_testing() -> Arc<Self> {
let registry = Registry::new();
Self::new(&registry)
}

/// Publish a fresh `GetServiceInfo` answer: flip the previously-active
/// child of [`Self::sui_node_info`] to `0`, the new one to `1`, and stamp
/// [`Self::sui_node_info_last_success_unixtime`].
///
/// Re-reporting the same pair is the common case (the version only changes
/// when the operator upgrades) and is cheap: it re-asserts `1` and
/// re-stamps the freshness gauge without touching any other child.
pub fn set_sui_node_info(&self, server_version: Option<&str>, chain_identifier: Option<&str>) {
self.flip_sui_node_info(
server_version.unwrap_or(SUI_NODE_INFO_UNREPORTED),
chain_identifier.unwrap_or(SUI_NODE_INFO_UNREPORTED),
);
self.sui_node_info_last_success_unixtime
.set(unix_seconds_now());
}

/// Mark this client's uplink as having no service-info RPC — a peer-only
/// validator reading through the p2p mirror relay.
///
/// Deliberately does *not* stamp the freshness gauge: there is no fullnode
/// to be fresh about, and leaving it at `0` keeps "never answered" and
/// "cannot answer" apart in queries by the label alone.
pub fn set_sui_node_info_unsupported(&self) {
self.flip_sui_node_info(SUI_NODE_INFO_UNSUPPORTED, SUI_NODE_INFO_UNSUPPORTED);
}

fn flip_sui_node_info(&self, server_version: &str, chain_identifier: &str) {
// A poisoned lock here carries no invariant worth propagating — the
// guarded value is two label strings — and this runs on a background
// refresh whose whole contract is to never disturb the client.
let mut active = self
.active_sui_node_info
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if active[0] != server_version || active[1] != chain_identifier {
self.sui_node_info
.with_label_values(&[active[0].as_str(), active[1].as_str()])
.set(0);
*active = [server_version.to_string(), chain_identifier.to_string()];
}
self.sui_node_info
.with_label_values(&[server_version, chain_identifier])
.set(1);
}
}

fn unix_seconds_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
Loading