v0.3.14-pre.003
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/lib.rs
|
||||
// version: 47
|
||||
// version: 48
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
@@ -555,6 +555,8 @@ pub use self::ws_protocol_session::HeliusLaserStreamWsSession;
|
||||
pub use self::ws_protocol_session::SolanaStandardWsSession;
|
||||
/// Shareable compatibility handle for one explicitly created standard Solana physical WebSocket session.
|
||||
pub use self::ws_session::WsSession;
|
||||
/// Cloneable latest-value observer for one safe physical WebSocket session snapshot.
|
||||
pub use self::ws_session::WsSessionSnapshotSource;
|
||||
/// Open cluster or network descriptor used by WebSocket endpoint settings.
|
||||
pub use self::ws_settings::WsClusterName;
|
||||
/// Runtime settings for one named WebSocket endpoint.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/ws_protocol_session.rs
|
||||
// version: 6
|
||||
// version: 7
|
||||
|
||||
/// Typed facade for one standard Solana WebSocket physical session.
|
||||
///
|
||||
@@ -42,6 +42,12 @@ impl SolanaStandardWsSession {
|
||||
return self.inner.snapshot();
|
||||
}
|
||||
|
||||
/// Returns a cloneable latest-value observer for safe physical-session snapshots from the shared actor.
|
||||
#[must_use]
|
||||
pub fn snapshot_source(&self) -> crate::WsSessionSnapshotSource {
|
||||
return self.inner.snapshot_source();
|
||||
}
|
||||
|
||||
/// Returns the latest observable physical-session state.
|
||||
#[must_use]
|
||||
pub fn state(&self) -> crate::WsSessionState {
|
||||
@@ -117,6 +123,12 @@ impl HeliusLaserStreamWsSession {
|
||||
return self.inner.snapshot();
|
||||
}
|
||||
|
||||
/// Returns a cloneable latest-value observer for safe physical-session snapshots from the shared actor.
|
||||
#[must_use]
|
||||
pub fn snapshot_source(&self) -> crate::WsSessionSnapshotSource {
|
||||
return self.inner.snapshot_source();
|
||||
}
|
||||
|
||||
/// Returns the latest observable physical-session state.
|
||||
#[must_use]
|
||||
pub fn state(&self) -> crate::WsSessionState {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/ws_session.rs
|
||||
// version: 14
|
||||
// version: 15
|
||||
|
||||
use futures_util::SinkExt; // rust-rules: trait-import
|
||||
use futures_util::StreamExt; // rust-rules: trait-import
|
||||
@@ -10,6 +10,43 @@ const HELIUS_WS_HEARTBEAT_INTERVAL: std::time::Duration = std::time::Duration::f
|
||||
|
||||
type WsPhysicalStream = tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
|
||||
|
||||
/// Cloneable latest-value observer for one physical WebSocket session snapshot.
|
||||
///
|
||||
/// The observer exposes only the already-safe [`crate::WsSessionSnapshot`] projection and keeps the internal Tokio watch channel private. Cloning it does
|
||||
/// not create another socket, actor, reconnect loop or subscription registry.
|
||||
#[derive(Clone)]
|
||||
pub struct WsSessionSnapshotSource {
|
||||
receiver: tokio::sync::watch::Receiver<crate::WsSessionSnapshot>,
|
||||
}
|
||||
|
||||
impl crate::WsSessionSnapshotSource {
|
||||
fn new(receiver: tokio::sync::watch::Receiver<crate::WsSessionSnapshot>) -> Self {
|
||||
return Self { receiver };
|
||||
}
|
||||
|
||||
/// Returns the current safe physical-session snapshot without waiting for another actor transition.
|
||||
#[must_use]
|
||||
pub fn current(&self) -> crate::WsSessionSnapshot {
|
||||
return (*self.receiver.borrow()).clone();
|
||||
}
|
||||
|
||||
/// Waits for one newer safe physical-session snapshot.
|
||||
///
|
||||
/// `None` means the owning WebSocket actor dropped the latest-value publisher and no further snapshot can arrive.
|
||||
pub async fn wait_for_change(&mut self) -> std::option::Option<crate::WsSessionSnapshot> {
|
||||
return match self.receiver.changed().await {
|
||||
std::result::Result::Ok(()) => std::option::Option::Some((*self.receiver.borrow_and_update()).clone()),
|
||||
std::result::Result::Err(_) => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for crate::WsSessionSnapshotSource {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.debug_struct("WsSessionSnapshotSource").field("current", &self.current()).finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Shareable handle for one explicitly created physical WebSocket session.
|
||||
///
|
||||
/// The handle never exposes the sensitive endpoint URL or the underlying socket. All socket I/O is owned by one internal actor task and all caller
|
||||
@@ -124,6 +161,12 @@ impl WsSession {
|
||||
return self.snapshot_rx.borrow().clone();
|
||||
}
|
||||
|
||||
/// Returns a cloneable latest-value observer for safe physical-session snapshots.
|
||||
#[must_use]
|
||||
pub fn snapshot_source(&self) -> crate::WsSessionSnapshotSource {
|
||||
return crate::WsSessionSnapshotSource::new(self.snapshot_rx.clone());
|
||||
}
|
||||
|
||||
/// Returns the latest observable physical-session state.
|
||||
#[must_use]
|
||||
pub fn state(&self) -> crate::WsSessionState {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/tests/public_api.rs
|
||||
// version: 52
|
||||
// version: 53
|
||||
|
||||
//! Integration tests for the public `ksp-onchain-transport-lib` consumer contract.
|
||||
|
||||
@@ -569,6 +569,17 @@ fn public_v0_2_7_pre_004_physical_websocket_session_contract_is_available_from_c
|
||||
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_WS_SESSION_CLOSED.code(), "ws_session_closed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_v0_3_14_pre_003_websocket_snapshot_source_is_available_from_crate_root() {
|
||||
let _physical = ksp_onchain_transport_lib::WsSession::snapshot_source;
|
||||
let _standard = ksp_onchain_transport_lib::SolanaStandardWsSession::snapshot_source;
|
||||
let _helius = ksp_onchain_transport_lib::HeliusLaserStreamWsSession::snapshot_source;
|
||||
let _current = ksp_onchain_transport_lib::WsSessionSnapshotSource::current;
|
||||
let _wait = ksp_onchain_transport_lib::WsSessionSnapshotSource::wait_for_change;
|
||||
assert!(std::any::type_name::<ksp_onchain_transport_lib::WsSessionSnapshotSource>().ends_with("WsSessionSnapshotSource"));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_v0_2_7_pre_005_bounded_websocket_close_contract_is_available_from_crate_root() {
|
||||
let _close = ksp_onchain_transport_lib::WsSession::close;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/tests/release_completeness.rs
|
||||
// version: 44
|
||||
// version: 45
|
||||
|
||||
//! Release-level completeness canaries for staged HTTP and WebSocket Transport coverage.
|
||||
|
||||
@@ -1354,3 +1354,23 @@ fn release_v0_3_13_pre_002_yellowstone_subscribe_identity_remains_opaque_and_dep
|
||||
assert!(!root.contains("pub use yellowstone_grpc_proto"));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_3_14_pre_003_ws_snapshot_source_reuses_actor_watch_without_second_runtime() {
|
||||
let session_source = include_str!("../src/ws_session.rs");
|
||||
let facade_source = include_str!("../src/ws_protocol_session.rs");
|
||||
let session_tests = include_str!("../unit_tests/ws_session.rs");
|
||||
assert!(session_source.contains("pub struct WsSessionSnapshotSource"));
|
||||
assert!(session_source.contains("self.snapshot_rx.clone()"));
|
||||
assert!(facade_source.matches("pub fn snapshot_source").count() >= 2);
|
||||
for required in [
|
||||
"v0_3_14_pre_003_snapshot_source_tracks_latest_value_without_owning_runtime",
|
||||
"websocket_reconnect_resubscribes_in_local_id_order_and_remaps_remote_ids",
|
||||
"websocket_notification_queue_overflow_fails_only_slow_subscription_and_cleans_remote_binding",
|
||||
] {
|
||||
assert!(session_tests.contains(required), "missing pre.003 WebSocket continuity test: {required}");
|
||||
}
|
||||
assert!(!session_source.contains("WsSessionSnapshotSource {\n socket:"));
|
||||
assert!(!facade_source.contains("tokio_tungstenite::connect_async"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_session.rs
|
||||
// version: 11
|
||||
// version: 12
|
||||
|
||||
use futures_util::SinkExt; // rust-rules: trait-import
|
||||
use futures_util::StreamExt; // rust-rules: trait-import
|
||||
@@ -1355,3 +1355,40 @@ async fn websocket_dropped_notification_receiver_triggers_remote_cleanup_and_rel
|
||||
session.close().await.expect("session close must remain bounded");
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn v0_3_14_pre_003_snapshot_source_tracks_latest_value_without_owning_runtime() {
|
||||
let session_id = crate::WsSessionId::new(std::num::NonZeroU64::new(1).expect("test session ID must be non-zero"));
|
||||
let initial = crate::WsSessionSnapshot::new(
|
||||
session_id,
|
||||
"local_ws",
|
||||
crate::WsProviderName::new("local-fixture"),
|
||||
crate::WsClusterName::new("local"),
|
||||
crate::WsProtocolKind::SolanaStandard,
|
||||
crate::WsSessionState::Active,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
std::vec::Vec::new(),
|
||||
);
|
||||
let reconnecting = crate::WsSessionSnapshot::new(
|
||||
session_id,
|
||||
"local_ws",
|
||||
crate::WsProviderName::new("local-fixture"),
|
||||
crate::WsClusterName::new("local"),
|
||||
crate::WsProtocolKind::SolanaStandard,
|
||||
crate::WsSessionState::Reconnecting { attempt: 1 },
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
std::vec::Vec::new(),
|
||||
);
|
||||
let (sender, receiver) = tokio::sync::watch::channel(initial.clone());
|
||||
let mut source = crate::WsSessionSnapshotSource::new(receiver);
|
||||
assert_eq!(source.current(), initial);
|
||||
sender.send_replace(reconnecting.clone());
|
||||
assert_eq!(source.wait_for_change().await, std::option::Option::Some(reconnecting));
|
||||
drop(sender);
|
||||
assert_eq!(source.wait_for_change().await, std::option::Option::None);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-worker-raw-transaction-ingest-lib/src/continuity.rs
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
/// Maximum number of simultaneously retained non-repaired run-local gaps.
|
||||
const MAX_RAW_TRANSACTION_INGEST_OPEN_REPAIR_GAPS: usize = 64;
|
||||
@@ -150,6 +150,85 @@ impl RawTransactionIngestGapRange {
|
||||
}
|
||||
}
|
||||
|
||||
/// Private run-local anchor for one WebSocket continuity incident.
|
||||
///
|
||||
/// The anchor never derives slots from wall-clock time. Its inclusive start is the latest slot actually observed by that source before Transport reported a
|
||||
/// reconnect or notification overflow; the end remains absent until the first post-incident source slot is observed.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct RawTransactionIngestWebSocketIncidentAnchor {
|
||||
end_slot: std::option::Option<u64>,
|
||||
overflow_total: u64,
|
||||
reconnect_total: u64,
|
||||
saw_overflow: bool,
|
||||
saw_reconnect: bool,
|
||||
start_slot: u64,
|
||||
}
|
||||
|
||||
impl crate::RawTransactionIngestWebSocketIncidentAnchor {
|
||||
/// Creates one open incident anchor from monotone Transport counters and one actually observed source slot.
|
||||
pub(crate) fn new(start_slot: u64, reconnect_total: u64, overflow_total: u64, saw_reconnect: bool, saw_overflow: bool) -> ksp_core_lib::Result<Self> {
|
||||
if !saw_reconnect && !saw_overflow {
|
||||
return std::result::Result::Err(crate::runtime_error("continuity.websocket_incident_reason_missing"));
|
||||
}
|
||||
return std::result::Result::Ok(Self {
|
||||
end_slot: std::option::Option::None,
|
||||
overflow_total,
|
||||
reconnect_total,
|
||||
saw_overflow,
|
||||
saw_reconnect,
|
||||
start_slot,
|
||||
});
|
||||
}
|
||||
|
||||
/// Extends one still-open incident with newer monotone Transport counters without changing its earliest start slot.
|
||||
pub(crate) fn extend(&mut self, reconnect_total: u64, overflow_total: u64, saw_reconnect: bool, saw_overflow: bool) -> ksp_core_lib::Result<()> {
|
||||
if self.end_slot.is_some() {
|
||||
return std::result::Result::Err(crate::runtime_error("continuity.websocket_incident_already_closed"));
|
||||
}
|
||||
if reconnect_total < self.reconnect_total || overflow_total < self.overflow_total {
|
||||
return std::result::Result::Err(crate::runtime_error("continuity.websocket_incident_counter_regression"));
|
||||
}
|
||||
self.reconnect_total = reconnect_total;
|
||||
self.overflow_total = overflow_total;
|
||||
self.saw_reconnect = self.saw_reconnect || saw_reconnect;
|
||||
self.saw_overflow = self.saw_overflow || saw_overflow;
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
/// Closes the inclusive incident range at the first post-incident source slot.
|
||||
pub(crate) fn close_at(&mut self, end_slot: u64) -> ksp_core_lib::Result<()> {
|
||||
let range = match RawTransactionIngestGapRange::new(self.start_slot, end_slot) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
self.end_slot = std::option::Option::Some(range.end_slot());
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
/// Returns the first source slot that safely anchors the incident, inclusively.
|
||||
#[cfg(test)]
|
||||
pub(crate) const fn start_slot(self) -> u64 {
|
||||
return self.start_slot;
|
||||
}
|
||||
|
||||
/// Returns the first post-incident source slot when the incident has become range-bounded.
|
||||
pub(crate) const fn end_slot(self) -> std::option::Option<u64> {
|
||||
return self.end_slot;
|
||||
}
|
||||
|
||||
/// Returns whether at least one physical WebSocket reconnect contributed to this incident.
|
||||
#[cfg(test)]
|
||||
pub(crate) const fn saw_reconnect(self) -> bool {
|
||||
return self.saw_reconnect;
|
||||
}
|
||||
|
||||
/// Returns whether at least one Transport notification overflow contributed to this incident.
|
||||
#[cfg(test)]
|
||||
pub(crate) const fn saw_overflow(self) -> bool {
|
||||
return self.saw_overflow;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
|
||||
struct RawTransactionIngestGapId(u64);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-worker-raw-transaction-ingest-lib/src/lib.rs
|
||||
// version: 28
|
||||
// version: 29
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
@@ -117,6 +117,8 @@ pub(crate) use self::continuity::RawTransactionIngestContinuityCapabilityDescrip
|
||||
pub(crate) use self::continuity::RawTransactionIngestContinuityContracts;
|
||||
/// Private provider-neutral coverage scope used by run-local continuity proof contracts.
|
||||
pub(crate) use self::continuity::RawTransactionIngestCoverageScope;
|
||||
/// Private run-local WebSocket incident anchor built only from observed source slots and safe Transport counters.
|
||||
pub(crate) use self::continuity::RawTransactionIngestWebSocketIncidentAnchor;
|
||||
/// Creates one terminal content-conflict error without copying conflicting material into diagnostics.
|
||||
pub(crate) use self::error::content_conflict_error;
|
||||
/// Creates one terminal counter-exhaustion error without exposing runtime material.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-worker-raw-transaction-ingest-lib/src/runtime_resources.rs
|
||||
// version: 29
|
||||
// version: 30
|
||||
|
||||
use sha2::Digest; // rust-rules: trait-import
|
||||
|
||||
@@ -1055,12 +1055,17 @@ impl crate::RawTransactionIngestHeliusTransactionSource {
|
||||
return std::result::Result::Err(source_transport_error(error.code()));
|
||||
},
|
||||
};
|
||||
let mut session_snapshot_source = session.snapshot_source();
|
||||
let hydration = self.hydration_context();
|
||||
let mut coordinator =
|
||||
RawTransactionIngestHydrationCoordinator::with_global_registry(global_hydration_registry, hydration_pending_limit, hydration_in_flight_limit);
|
||||
let mut processing_frontier = RawTransactionIngestProcessingFrontierReporter::new(processing_frontier_sender);
|
||||
processing_frontier.set_source_state(crate::RawTransactionIngestSourceState::Active);
|
||||
if let std::result::Result::Err(error) = processing_frontier.observe_websocket_session_snapshot(session_snapshot_source.current()) {
|
||||
let _closed = session.close().await;
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let mut fault = std::option::Option::None;
|
||||
let mut bounded_websocket_incident = false;
|
||||
loop {
|
||||
if *stop_receiver.borrow() {
|
||||
break;
|
||||
@@ -1069,7 +1074,11 @@ impl crate::RawTransactionIngestHeliusTransactionSource {
|
||||
fault = std::option::Option::Some(error);
|
||||
break;
|
||||
}
|
||||
let can_receive = coordinator.can_receive();
|
||||
if bounded_websocket_incident && coordinator.pending_signal_count == 0 && coordinator.tasks.is_empty() {
|
||||
fault = std::option::Option::Some(crate::runtime_error("source.continuity_gap_proven"));
|
||||
break;
|
||||
}
|
||||
let can_receive = coordinator.can_receive() && !bounded_websocket_incident;
|
||||
if !can_receive && coordinator.tasks.is_empty() {
|
||||
fault = std::option::Option::Some(crate::runtime_error("source.hydration_stalled"));
|
||||
break;
|
||||
@@ -1079,6 +1088,19 @@ impl crate::RawTransactionIngestHeliusTransactionSource {
|
||||
_ = stop_receiver.changed() => {
|
||||
break;
|
||||
}
|
||||
source_snapshot = session_snapshot_source.wait_for_change() => {
|
||||
let source_snapshot = match source_snapshot {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
fault = std::option::Option::Some(crate::runtime_error("source.websocket_snapshot_closed"));
|
||||
break;
|
||||
},
|
||||
};
|
||||
if let std::result::Result::Err(error) = processing_frontier.observe_websocket_session_snapshot(source_snapshot) {
|
||||
fault = std::option::Option::Some(error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
joined = coordinator.tasks.join_next(), if !coordinator.tasks.is_empty() => {
|
||||
let joined = match joined {
|
||||
std::option::Option::Some(value) => value,
|
||||
@@ -1143,10 +1165,18 @@ impl crate::RawTransactionIngestHeliusTransactionSource {
|
||||
break;
|
||||
},
|
||||
};
|
||||
let incident_bounded = match processing_frontier.observe_websocket_post_incident_slot(signal.slot) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
fault = std::option::Option::Some(error);
|
||||
break;
|
||||
},
|
||||
};
|
||||
if let std::result::Result::Err(error) = coordinator.queue_signal(&hydration, signal, received_at, &mut processing_frontier) {
|
||||
fault = std::option::Option::Some(error);
|
||||
break;
|
||||
}
|
||||
bounded_websocket_incident = bounded_websocket_incident || incident_bounded;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1541,8 +1571,12 @@ impl crate::RawTransactionIngestStandardBlockSource {
|
||||
return std::result::Result::Err(source_transport_error(error.code()));
|
||||
},
|
||||
};
|
||||
let mut session_snapshot_source = session.snapshot_source();
|
||||
let mut processing_frontier = RawTransactionIngestProcessingFrontierReporter::new(processing_frontier_sender);
|
||||
processing_frontier.set_source_state(crate::RawTransactionIngestSourceState::Active);
|
||||
if let std::result::Result::Err(error) = processing_frontier.observe_websocket_session_snapshot(session_snapshot_source.current()) {
|
||||
let _closed = session.close().await;
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let mut fault = std::option::Option::None;
|
||||
'source: loop {
|
||||
if *stop_receiver.borrow() {
|
||||
@@ -1553,6 +1587,20 @@ impl crate::RawTransactionIngestStandardBlockSource {
|
||||
_ = stop_receiver.changed() => {
|
||||
break;
|
||||
}
|
||||
source_snapshot = session_snapshot_source.wait_for_change() => {
|
||||
let source_snapshot = match source_snapshot {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
fault = std::option::Option::Some(crate::runtime_error("source.websocket_snapshot_closed"));
|
||||
break;
|
||||
},
|
||||
};
|
||||
if let std::result::Result::Err(error) = processing_frontier.observe_websocket_session_snapshot(source_snapshot) {
|
||||
fault = std::option::Option::Some(error);
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
value = subscription.recv() => value,
|
||||
};
|
||||
let notification = match notification {
|
||||
@@ -1584,11 +1632,22 @@ impl crate::RawTransactionIngestStandardBlockSource {
|
||||
break;
|
||||
},
|
||||
};
|
||||
let incident_bounded = match processing_frontier.observe_websocket_post_incident_slot(slot) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
fault = std::option::Option::Some(error);
|
||||
break;
|
||||
},
|
||||
};
|
||||
if ingresses.is_empty() {
|
||||
if let std::result::Result::Err(error) = processing_frontier.observe_settled(slot) {
|
||||
fault = std::option::Option::Some(error);
|
||||
break;
|
||||
}
|
||||
if incident_bounded {
|
||||
fault = std::option::Option::Some(crate::runtime_error("source.continuity_gap_proven"));
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for ingress in ingresses {
|
||||
@@ -1611,6 +1670,10 @@ impl crate::RawTransactionIngestStandardBlockSource {
|
||||
fault = std::option::Option::Some(error);
|
||||
break;
|
||||
}
|
||||
if incident_bounded {
|
||||
fault = std::option::Option::Some(crate::runtime_error("source.continuity_gap_proven"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
processing_frontier.discard_all_pending();
|
||||
processing_frontier.set_source_state(crate::RawTransactionIngestSourceState::Closing);
|
||||
@@ -1777,12 +1840,17 @@ impl crate::RawTransactionIngestStandardLogsSource {
|
||||
return std::result::Result::Err(source_transport_error(error.code()));
|
||||
},
|
||||
};
|
||||
let mut session_snapshot_source = session.snapshot_source();
|
||||
let hydration = self.hydration_context();
|
||||
let mut coordinator =
|
||||
RawTransactionIngestHydrationCoordinator::with_global_registry(global_hydration_registry, hydration_pending_limit, hydration_in_flight_limit);
|
||||
let mut processing_frontier = RawTransactionIngestProcessingFrontierReporter::new(processing_frontier_sender);
|
||||
processing_frontier.set_source_state(crate::RawTransactionIngestSourceState::Active);
|
||||
if let std::result::Result::Err(error) = processing_frontier.observe_websocket_session_snapshot(session_snapshot_source.current()) {
|
||||
let _closed = session.close().await;
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let mut fault = std::option::Option::None;
|
||||
let mut bounded_websocket_incident = false;
|
||||
loop {
|
||||
if *stop_receiver.borrow() {
|
||||
break;
|
||||
@@ -1791,7 +1859,11 @@ impl crate::RawTransactionIngestStandardLogsSource {
|
||||
fault = std::option::Option::Some(error);
|
||||
break;
|
||||
}
|
||||
let can_receive = coordinator.can_receive();
|
||||
if bounded_websocket_incident && coordinator.pending_signal_count == 0 && coordinator.tasks.is_empty() {
|
||||
fault = std::option::Option::Some(crate::runtime_error("source.continuity_gap_proven"));
|
||||
break;
|
||||
}
|
||||
let can_receive = coordinator.can_receive() && !bounded_websocket_incident;
|
||||
if !can_receive && coordinator.tasks.is_empty() {
|
||||
fault = std::option::Option::Some(crate::runtime_error("source.hydration_stalled"));
|
||||
break;
|
||||
@@ -1801,6 +1873,19 @@ impl crate::RawTransactionIngestStandardLogsSource {
|
||||
_ = stop_receiver.changed() => {
|
||||
break;
|
||||
}
|
||||
source_snapshot = session_snapshot_source.wait_for_change() => {
|
||||
let source_snapshot = match source_snapshot {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
fault = std::option::Option::Some(crate::runtime_error("source.websocket_snapshot_closed"));
|
||||
break;
|
||||
},
|
||||
};
|
||||
if let std::result::Result::Err(error) = processing_frontier.observe_websocket_session_snapshot(source_snapshot) {
|
||||
fault = std::option::Option::Some(error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
joined = coordinator.tasks.join_next(), if !coordinator.tasks.is_empty() => {
|
||||
let joined = match joined {
|
||||
std::option::Option::Some(value) => value,
|
||||
@@ -1858,10 +1943,18 @@ impl crate::RawTransactionIngestStandardLogsSource {
|
||||
break;
|
||||
},
|
||||
};
|
||||
let incident_bounded = match processing_frontier.observe_websocket_post_incident_slot(signal.slot) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
fault = std::option::Option::Some(error);
|
||||
break;
|
||||
},
|
||||
};
|
||||
if let std::result::Result::Err(error) = coordinator.queue_signal(&hydration, signal, received_at, &mut processing_frontier) {
|
||||
fault = std::option::Option::Some(error);
|
||||
break;
|
||||
}
|
||||
bounded_websocket_incident = bounded_websocket_incident || incident_bounded;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3364,6 +3457,18 @@ fn map_yellowstone_source_state(state: ksp_onchain_transport_lib::YellowstoneGrp
|
||||
};
|
||||
}
|
||||
|
||||
fn map_websocket_source_state(state: ksp_onchain_transport_lib::WsSessionState) -> crate::RawTransactionIngestSourceState {
|
||||
return match state {
|
||||
ksp_onchain_transport_lib::WsSessionState::Disconnected
|
||||
| ksp_onchain_transport_lib::WsSessionState::Connecting
|
||||
| ksp_onchain_transport_lib::WsSessionState::Reconnecting { .. } => crate::RawTransactionIngestSourceState::Reconnecting,
|
||||
ksp_onchain_transport_lib::WsSessionState::Active => crate::RawTransactionIngestSourceState::Active,
|
||||
ksp_onchain_transport_lib::WsSessionState::Closing => crate::RawTransactionIngestSourceState::Closing,
|
||||
ksp_onchain_transport_lib::WsSessionState::Closed => crate::RawTransactionIngestSourceState::Closed,
|
||||
ksp_onchain_transport_lib::WsSessionState::Failed => crate::RawTransactionIngestSourceState::Failed,
|
||||
};
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
struct RawTransactionIngestProcessingSlotState {
|
||||
pending: usize,
|
||||
@@ -3447,6 +3552,10 @@ impl RawTransactionIngestProcessingFrontier {
|
||||
return;
|
||||
}
|
||||
|
||||
fn highest_observed_slot(&self) -> std::option::Option<u64> {
|
||||
return self.slots.keys().next_back().copied();
|
||||
}
|
||||
|
||||
fn projection(&self) -> crate::RawTransactionIngestProcessingFrontierProjection {
|
||||
let oldest_pending_slot = self.slots.iter().find_map(|(slot, state)| {
|
||||
if state.pending == 0 {
|
||||
@@ -3527,6 +3636,8 @@ struct RawTransactionIngestProcessingFrontierReporter {
|
||||
source_reconnect_total: u64,
|
||||
source_replay_attempt_total: u64,
|
||||
source_continuity_gap_total: u64,
|
||||
source_overflow_total: u64,
|
||||
websocket_incident_anchor: std::option::Option<crate::RawTransactionIngestWebSocketIncidentAnchor>,
|
||||
}
|
||||
|
||||
impl RawTransactionIngestProcessingFrontierReporter {
|
||||
@@ -3538,6 +3649,8 @@ impl RawTransactionIngestProcessingFrontierReporter {
|
||||
source_reconnect_total: 0,
|
||||
source_replay_attempt_total: 0,
|
||||
source_continuity_gap_total: 0,
|
||||
source_overflow_total: 0,
|
||||
websocket_incident_anchor: std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3580,6 +3693,85 @@ impl RawTransactionIngestProcessingFrontierReporter {
|
||||
);
|
||||
}
|
||||
|
||||
fn observe_websocket_session_snapshot(&mut self, snapshot: ksp_onchain_transport_lib::WsSessionSnapshot) -> ksp_core_lib::Result<()> {
|
||||
return self.observe_websocket_continuity(map_websocket_source_state(snapshot.state()), snapshot.continuity_gap_count(), snapshot.overflow_count());
|
||||
}
|
||||
|
||||
fn observe_websocket_continuity(
|
||||
&mut self,
|
||||
state: crate::RawTransactionIngestSourceState,
|
||||
reconnect_total: u64,
|
||||
overflow_total: u64,
|
||||
) -> ksp_core_lib::Result<()> {
|
||||
if reconnect_total < self.source_reconnect_total || overflow_total < self.source_overflow_total {
|
||||
return std::result::Result::Err(crate::runtime_error("source.websocket_continuity_counter_regression"));
|
||||
}
|
||||
let continuity_gap_total = match reconnect_total.checked_add(overflow_total) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::counter_exhausted_error("source.websocket_continuity_gap_total")),
|
||||
};
|
||||
if continuity_gap_total < self.source_continuity_gap_total {
|
||||
return std::result::Result::Err(crate::runtime_error("source.continuity_counter_regression"));
|
||||
}
|
||||
let saw_reconnect = reconnect_total > self.source_reconnect_total;
|
||||
let saw_overflow = overflow_total > self.source_overflow_total;
|
||||
self.source_reconnect_total = reconnect_total;
|
||||
self.source_replay_attempt_total = 0;
|
||||
self.source_continuity_gap_total = continuity_gap_total;
|
||||
self.source_overflow_total = overflow_total;
|
||||
if saw_reconnect || saw_overflow {
|
||||
let start_slot = match self.frontier.highest_observed_slot() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
self.publish();
|
||||
return std::result::Result::Err(crate::runtime_error("source.websocket_incident_unbounded"));
|
||||
},
|
||||
};
|
||||
match self.websocket_incident_anchor.as_mut() {
|
||||
std::option::Option::Some(anchor) => {
|
||||
if let std::result::Result::Err(error) = anchor.extend(reconnect_total, overflow_total, saw_reconnect, saw_overflow) {
|
||||
self.publish();
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
},
|
||||
std::option::Option::None => {
|
||||
let anchor =
|
||||
match crate::RawTransactionIngestWebSocketIncidentAnchor::new(start_slot, reconnect_total, overflow_total, saw_reconnect, saw_overflow)
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
self.publish();
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
self.websocket_incident_anchor = std::option::Option::Some(anchor);
|
||||
},
|
||||
}
|
||||
}
|
||||
self.source_state = if self.websocket_incident_anchor.is_some() && state == crate::RawTransactionIngestSourceState::Active {
|
||||
std::option::Option::Some(crate::RawTransactionIngestSourceState::Reconnecting)
|
||||
} else {
|
||||
std::option::Option::Some(state)
|
||||
};
|
||||
self.publish();
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn observe_websocket_post_incident_slot(&mut self, slot: u64) -> ksp_core_lib::Result<bool> {
|
||||
let anchor = match self.websocket_incident_anchor.as_mut() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Ok(false),
|
||||
};
|
||||
if anchor.end_slot().is_some() {
|
||||
return std::result::Result::Ok(false);
|
||||
}
|
||||
if let std::result::Result::Err(error) = anchor.close_at(slot) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
self.publish();
|
||||
return std::result::Result::Ok(true);
|
||||
}
|
||||
|
||||
fn observe_source_continuity(
|
||||
&mut self,
|
||||
state: crate::RawTransactionIngestSourceState,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// file: crates/ksp-worker-raw-transaction-ingest-lib/tests/hardening.rs
|
||||
// version: 26
|
||||
// version: 27
|
||||
|
||||
//! External public, security, redaction and release-boundary hardening canaries through `v0.3.14-pre.002`.
|
||||
//! External public, security, redaction and release-boundary hardening canaries through `v0.3.14-pre.003`.
|
||||
|
||||
fn network(value: &'static str) -> std::option::Option<ksp_store_lib::RawNetworkId> {
|
||||
let result = ksp_store_lib::RawNetworkId::new(value);
|
||||
@@ -1012,3 +1012,15 @@ fn v0_3_14_pre_002_continuity_contracts_are_private_bounded_and_io_free() {
|
||||
assert!(root.contains("pub(crate) use self::continuity::RawTransactionIngestContinuityContracts;"));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v0_3_14_pre_003_websocket_continuity_observation_reuses_transport_snapshot_sources_only() {
|
||||
let worker = include_str!("../src/runtime_resources.rs");
|
||||
assert!(worker.matches("session.snapshot_source()").count() >= 3);
|
||||
assert!(worker.contains("observe_websocket_session_snapshot"));
|
||||
assert!(worker.contains("observe_websocket_post_incident_slot"));
|
||||
assert!(!worker.contains("tokio_tungstenite::connect_async"));
|
||||
assert!(!worker.contains("WebSocketStream<"));
|
||||
assert!(!worker.contains("set_from_slot("));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// file: crates/ksp-worker-raw-transaction-ingest-lib/tests/release_completeness.rs
|
||||
// version: 21
|
||||
// version: 22
|
||||
|
||||
//! Release-completeness canaries through the `v0.3.14-pre.002` continuity-contract tranche.
|
||||
//! Release-completeness canaries through the `v0.3.14-pre.003` WebSocket continuity-observation tranche.
|
||||
|
||||
#[test]
|
||||
fn pre_010_production_module_inventory_is_exact() -> std::io::Result<()> {
|
||||
@@ -144,6 +144,7 @@ fn pre_010_external_hardening_suite_is_present_and_scoped() {
|
||||
"v0_3_13_pre_010_multi_source_health_is_conservative_counted_and_redacted",
|
||||
"v0_3_13_pre_011_shutdown_races_are_bounded_joined_atomic_and_counter_safe",
|
||||
"v0_3_14_pre_002_continuity_contracts_are_private_bounded_and_io_free",
|
||||
"v0_3_14_pre_003_websocket_continuity_observation_reuses_transport_snapshot_sources_only",
|
||||
] {
|
||||
assert!(hardening.contains(required), "required pre.010 hardening canary missing: {required}");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-worker-raw-transaction-ingest-lib/unit_tests/continuity.rs
|
||||
// version: 2
|
||||
// version: 3
|
||||
|
||||
fn network() -> std::option::Option<ksp_store_lib::RawNetworkId> {
|
||||
return match ksp_store_lib::RawNetworkId::new("mainnet") {
|
||||
@@ -239,3 +239,39 @@ fn pre_002_gap_ledger_enforces_open_gap_bound_and_next_id_monotonicity() {
|
||||
assert!(stale_next.validate_invariants().is_err());
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_websocket_incident_anchor_is_inclusive_monotone_and_bounded() {
|
||||
let mut anchor = match crate::RawTransactionIngestWebSocketIncidentAnchor::new(100, 1, 0, true, false) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("unexpected anchor failure: {error}"),
|
||||
};
|
||||
assert_eq!(anchor.start_slot(), 100);
|
||||
assert_eq!(anchor.end_slot(), std::option::Option::None);
|
||||
assert!(anchor.saw_reconnect());
|
||||
assert!(!anchor.saw_overflow());
|
||||
assert!(anchor.extend(2, 1, true, true).is_ok());
|
||||
assert!(anchor.saw_reconnect());
|
||||
assert!(anchor.saw_overflow());
|
||||
assert!(anchor.extend(1, 1, false, false).is_err());
|
||||
assert!(anchor.close_at(100 + super::MAX_RAW_TRANSACTION_INGEST_REPAIR_RANGE_SLOTS - 1).is_ok());
|
||||
assert_eq!(anchor.end_slot(), std::option::Option::Some(100 + super::MAX_RAW_TRANSACTION_INGEST_REPAIR_RANGE_SLOTS - 1));
|
||||
assert!(anchor.extend(3, 1, true, false).is_err());
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_websocket_incident_anchor_rejects_missing_reason_reversal_and_oversize() {
|
||||
assert!(crate::RawTransactionIngestWebSocketIncidentAnchor::new(100, 0, 0, false, false).is_err());
|
||||
let mut reversed = match crate::RawTransactionIngestWebSocketIncidentAnchor::new(100, 0, 1, false, true) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("unexpected overflow anchor failure: {error}"),
|
||||
};
|
||||
assert!(reversed.close_at(99).is_err());
|
||||
let mut oversized = match crate::RawTransactionIngestWebSocketIncidentAnchor::new(100, 1, 0, true, false) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("unexpected oversize anchor fixture failure: {error}"),
|
||||
};
|
||||
assert!(oversized.close_at(100 + super::MAX_RAW_TRANSACTION_INGEST_REPAIR_RANGE_SLOTS).is_err());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-worker-raw-transaction-ingest-lib/unit_tests/runtime_resources.rs
|
||||
// version: 23
|
||||
// version: 24
|
||||
|
||||
fn grpc_endpoint(cluster: &str) -> std::option::Option<ksp_onchain_transport_lib::YellowstoneGrpcEndpointSettings> {
|
||||
return grpc_endpoint_with_identity(cluster, "yellowstone-fixture", "fixture-provider");
|
||||
@@ -3347,3 +3347,73 @@ async fn v0_3_13_pre_011_stop_racing_ready_source_fault_preserves_fault_and_join
|
||||
assert_eq!(sibling_active.load(std::sync::atomic::Ordering::Acquire), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v0_3_14_pre_003_websocket_reconnect_is_anchored_before_terminal_gap_projection() {
|
||||
let (sender, receiver) = tokio::sync::watch::channel(crate::RawTransactionIngestProcessingFrontierProjection::empty());
|
||||
let mut reporter = super::RawTransactionIngestProcessingFrontierReporter::new(sender);
|
||||
assert!(reporter.observe_settled(100).is_ok());
|
||||
assert!(reporter.observe_websocket_continuity(crate::RawTransactionIngestSourceState::Active, 0, 0).is_ok());
|
||||
assert!(reporter.observe_websocket_continuity(crate::RawTransactionIngestSourceState::Reconnecting, 1, 0).is_ok());
|
||||
let anchor = match reporter.websocket_incident_anchor {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => panic!("reconnect incident anchor missing"),
|
||||
};
|
||||
assert_eq!(anchor.start_slot(), 100);
|
||||
assert_eq!(anchor.end_slot(), std::option::Option::None);
|
||||
assert!(anchor.saw_reconnect());
|
||||
assert!(!anchor.saw_overflow());
|
||||
assert!(reporter.observe_websocket_continuity(crate::RawTransactionIngestSourceState::Active, 1, 0).is_ok());
|
||||
let bounded = reporter.observe_websocket_post_incident_slot(105);
|
||||
assert!(matches!(bounded, std::result::Result::Ok(true)));
|
||||
let anchor = match reporter.websocket_incident_anchor {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => panic!("bounded reconnect incident anchor missing"),
|
||||
};
|
||||
assert_eq!(anchor.end_slot(), std::option::Option::Some(105));
|
||||
let projection = *receiver.borrow();
|
||||
assert_eq!(projection.source_reconnect_total(), 1);
|
||||
assert_eq!(projection.source_replay_attempt_total(), 0);
|
||||
assert_eq!(projection.source_continuity_gap_total(), 1);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v0_3_14_pre_003_websocket_overflow_and_reconnect_storm_share_earliest_anchor() {
|
||||
let (sender, receiver) = tokio::sync::watch::channel(crate::RawTransactionIngestProcessingFrontierProjection::empty());
|
||||
let mut reporter = super::RawTransactionIngestProcessingFrontierReporter::new(sender);
|
||||
assert!(reporter.observe_settled(200).is_ok());
|
||||
assert!(reporter.observe_websocket_continuity(crate::RawTransactionIngestSourceState::Active, 0, 1).is_ok());
|
||||
assert!(reporter.observe_websocket_continuity(crate::RawTransactionIngestSourceState::Reconnecting, 1, 2).is_ok());
|
||||
let anchor = match reporter.websocket_incident_anchor {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => panic!("overflow incident anchor missing"),
|
||||
};
|
||||
assert_eq!(anchor.start_slot(), 200);
|
||||
assert_eq!(anchor.end_slot(), std::option::Option::None);
|
||||
assert!(anchor.saw_reconnect());
|
||||
assert!(anchor.saw_overflow());
|
||||
let projection = *receiver.borrow();
|
||||
assert_eq!(projection.source_reconnect_total(), 1);
|
||||
assert_eq!(projection.source_continuity_gap_total(), 3);
|
||||
let bounded = reporter.observe_websocket_post_incident_slot(201);
|
||||
assert!(matches!(bounded, std::result::Result::Ok(true)));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v0_3_14_pre_003_websocket_incident_without_observed_slot_is_unbounded_and_counter_regression_fails() {
|
||||
let (sender, _receiver) = tokio::sync::watch::channel(crate::RawTransactionIngestProcessingFrontierProjection::empty());
|
||||
let mut reporter = super::RawTransactionIngestProcessingFrontierReporter::new(sender);
|
||||
let unbounded = reporter.observe_websocket_continuity(crate::RawTransactionIngestSourceState::Reconnecting, 1, 0);
|
||||
assert!(unbounded.is_err());
|
||||
let unbounded = match unbounded {
|
||||
std::result::Result::Ok(()) => return,
|
||||
std::result::Result::Err(value) => value,
|
||||
};
|
||||
assert!(unbounded.context().iter().any(|context| {
|
||||
return context.value() == "source.websocket_incident_unbounded";
|
||||
}));
|
||||
assert!(reporter.observe_websocket_continuity(crate::RawTransactionIngestSourceState::Active, 0, 0).is_err());
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user