v0.3.14-pre.004
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-onchain-transport-lib/USAGE.md -->
|
||||
<!-- version: 25 -->
|
||||
<!-- version: 26 -->
|
||||
|
||||
# Utilisation de `ksp-onchain-transport-lib`
|
||||
|
||||
@@ -387,9 +387,11 @@ let snapshot = stream.snapshot();
|
||||
let closed = stream.close().await;
|
||||
```
|
||||
|
||||
`try_update()` remplace dynamiquement la requête complète tant que la session est `Active`. Une mutation pendant `Reconnecting` est refusée pour éviter une application ambiguë. Le snapshot expose reconnects, replay attempts, gaps, duplicates, dernier `from_slot` demandé et dernier slot observé, sans endpoint ni payload arbitraire.
|
||||
`try_update()` remplace dynamiquement la requête complète tant que la session est `Active`. Une mutation pendant `Reconnecting` est refusée pour éviter une application ambiguë. Le snapshot expose reconnects, replay attempts, replay deliveries conservatrices, reprises dont la couverture reste non prouvée, gaps de rétention, duplicates, dernier `from_slot` demandé et dernier slot observé, sans endpoint ni payload arbitraire.
|
||||
|
||||
Le reconnect réutilise la dernière requête acceptée et peut avancer `from_slot`, mais le consumer doit traiter cette reprise comme best-effort. KSP ne promet ni exactly-once, ni replay historique complet, ni absence de fork/equivocation entre nœuds.
|
||||
Une `replay_delivery_count` n'augmente que lorsque le premier flux post-reconnect redélivre exactement le slot de reprise demandé. Elle prouve une livraison au bord de replay, pas la complétude de l'intervalle. Dès que le premier update slot-bearing atteint ou dépasse cette borne, `replay_coverage_unproven_count` augmente aussi : Transport ne possède pas de preuve générique que tous les updates correspondant aux filtres ont été livrés entre la borne et la reprise live. Le compteur augmente également si un nouveau reconnect survient avant tout matériau slot-bearing. Cette valeur signifie explicitement « couverture non prouvée » ; elle ne prouve pas qu'un événement filtré existait ou a été perdu.
|
||||
|
||||
Le reconnect réutilise la dernière requête acceptée et peut avancer `from_slot`, mais le consumer doit traiter cette reprise comme best-effort. `replay_attempt_count`, `replay_delivery_count` et une preuve de coverage sont des notions distinctes. Transport ne publie actuellement aucun compteur `replay_covered` générique, car ni l'acceptation de `from_slot` ni une livraison ponctuelle ne démontrent à elles seules une couverture historique complète. KSP ne promet ni exactly-once, ni replay historique complet, ni absence de fork/equivocation entre nœuds.
|
||||
|
||||
## 5. Appels typés
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/grpc_stream.rs
|
||||
// version: 4
|
||||
// version: 5
|
||||
|
||||
use tonic_prost::prost::Message; // rust-rules: trait-import
|
||||
|
||||
@@ -34,6 +34,8 @@ pub struct YellowstoneGrpcSubscribeSnapshot {
|
||||
continuity_gap_count: u64,
|
||||
duplicate_update_count: u64,
|
||||
replay_attempt_count: u64,
|
||||
replay_delivery_count: u64,
|
||||
replay_coverage_unproven_count: u64,
|
||||
last_requested_from_slot: std::option::Option<u64>,
|
||||
last_observed_slot: std::option::Option<u64>,
|
||||
terminal_error_code: std::option::Option<ksp_core_lib::ErrorCode>,
|
||||
@@ -84,6 +86,8 @@ impl YellowstoneGrpcSubscribeSnapshot {
|
||||
continuity_gap_count: 0,
|
||||
duplicate_update_count: 0,
|
||||
replay_attempt_count: 0,
|
||||
replay_delivery_count: 0,
|
||||
replay_coverage_unproven_count: 0,
|
||||
last_requested_from_slot: initial_from_slot,
|
||||
last_observed_slot: std::option::Option::None,
|
||||
terminal_error_code: std::option::Option::None,
|
||||
@@ -122,6 +126,25 @@ impl YellowstoneGrpcSubscribeSnapshot {
|
||||
return self.replay_attempt_count;
|
||||
}
|
||||
|
||||
/// Returns the number of replay-bearing reconnects that delivered the requested replay boundary slot again.
|
||||
///
|
||||
/// This is conservative delivery evidence only. It does not prove that every matching update in the replay interval was delivered and must never be
|
||||
/// interpreted as `replay_covered`.
|
||||
#[must_use]
|
||||
pub const fn replay_delivery_count(self) -> u64 {
|
||||
return self.replay_delivery_count;
|
||||
}
|
||||
|
||||
/// Returns the number of successful replay-bearing reconnects whose target coverage remains unproven.
|
||||
///
|
||||
/// The counter advances when the first post-reconnect slot-bearing update reaches or passes the requested replay boundary, because generic Transport
|
||||
/// cannot prove from that delivery alone that every matching update in the replay interval was delivered. It also advances when another reconnect starts
|
||||
/// before any slot-bearing replay material arrives. This is an explicit lack of coverage proof, not proof that a filtered event actually existed or was lost.
|
||||
#[must_use]
|
||||
pub const fn replay_coverage_unproven_count(self) -> u64 {
|
||||
return self.replay_coverage_unproven_count;
|
||||
}
|
||||
|
||||
/// Returns the most recent effective `from_slot` sent by KSP, including any clamp to `SubscribeReplayInfo.first_available`.
|
||||
#[must_use]
|
||||
pub const fn last_requested_from_slot(self) -> std::option::Option<u64> {
|
||||
@@ -506,17 +529,44 @@ enum UpdateIdentity {
|
||||
}
|
||||
|
||||
struct ContinuityTracker {
|
||||
pending_replay_from_slot: std::option::Option<u64>,
|
||||
recent_order: std::collections::VecDeque<UpdateIdentity>,
|
||||
recent_set: std::collections::HashSet<UpdateIdentity>,
|
||||
}
|
||||
|
||||
impl ContinuityTracker {
|
||||
fn new() -> Self {
|
||||
return Self { recent_order: std::collections::VecDeque::new(), recent_set: std::collections::HashSet::new() };
|
||||
return Self {
|
||||
pending_replay_from_slot: std::option::Option::None,
|
||||
recent_order: std::collections::VecDeque::new(),
|
||||
recent_set: std::collections::HashSet::new(),
|
||||
};
|
||||
}
|
||||
|
||||
fn begin_replay(&mut self, from_slot: std::option::Option<u64>) {
|
||||
self.pending_replay_from_slot = from_slot;
|
||||
return;
|
||||
}
|
||||
|
||||
fn abandon_pending_replay(&mut self, snapshot: &mut crate::YellowstoneGrpcSubscribeSnapshot) -> bool {
|
||||
if self.pending_replay_from_slot.take().is_none() {
|
||||
return false;
|
||||
}
|
||||
snapshot.replay_coverage_unproven_count = snapshot.replay_coverage_unproven_count.saturating_add(1);
|
||||
return true;
|
||||
}
|
||||
|
||||
fn observe(&mut self, update: &crate::YellowstoneSubscribeUpdate, snapshot: &mut crate::YellowstoneGrpcSubscribeSnapshot) {
|
||||
if let std::option::Option::Some(slot) = update_slot(update) {
|
||||
if let std::option::Option::Some(requested) = self.pending_replay_from_slot
|
||||
&& slot >= requested
|
||||
{
|
||||
if slot == requested {
|
||||
snapshot.replay_delivery_count = snapshot.replay_delivery_count.saturating_add(1);
|
||||
}
|
||||
snapshot.replay_coverage_unproven_count = snapshot.replay_coverage_unproven_count.saturating_add(1);
|
||||
self.pending_replay_from_slot = std::option::Option::None;
|
||||
}
|
||||
snapshot.last_observed_slot = std::option::Option::Some(match snapshot.last_observed_slot {
|
||||
std::option::Option::Some(previous) => std::cmp::max(previous, slot),
|
||||
std::option::Option::None => slot,
|
||||
@@ -620,6 +670,7 @@ async fn run_subscribe_actor(
|
||||
&mut shutdown_rx,
|
||||
&mut snapshot,
|
||||
&snapshot_tx,
|
||||
&mut tracker,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -662,6 +713,7 @@ async fn run_subscribe_actor(
|
||||
&mut shutdown_rx,
|
||||
&mut snapshot,
|
||||
&snapshot_tx,
|
||||
&mut tracker,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -701,8 +753,12 @@ async fn reconnect_subscribe_stream(
|
||||
shutdown_rx: &mut tokio::sync::watch::Receiver<std::option::Option<tokio::time::Instant>>,
|
||||
snapshot: &mut crate::YellowstoneGrpcSubscribeSnapshot,
|
||||
snapshot_tx: &tokio::sync::watch::Sender<crate::YellowstoneGrpcSubscribeSnapshot>,
|
||||
tracker: &mut ContinuityTracker,
|
||||
) -> ReconnectOutcome {
|
||||
clear_request_sender(request_state);
|
||||
if tracker.abandon_pending_replay(snapshot) {
|
||||
snapshot_tx.send_replace(*snapshot);
|
||||
}
|
||||
snapshot.state = crate::YellowstoneGrpcSubscribeState::Reconnecting;
|
||||
snapshot.terminal_error_code = std::option::Option::None;
|
||||
snapshot_tx.send_replace(*snapshot);
|
||||
@@ -785,6 +841,7 @@ async fn reconnect_subscribe_stream(
|
||||
return ReconnectOutcome::Exhausted(error);
|
||||
}
|
||||
snapshot.reconnect_count = snapshot.reconnect_count.saturating_add(1);
|
||||
tracker.begin_replay(effective_from_slot);
|
||||
snapshot.state = crate::YellowstoneGrpcSubscribeState::Active;
|
||||
snapshot.terminal_error_code = std::option::Option::None;
|
||||
snapshot_tx.send_replace(*snapshot);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/tests/public_api.rs
|
||||
// version: 53
|
||||
// version: 54
|
||||
|
||||
//! Integration tests for the public `ksp-onchain-transport-lib` consumer contract.
|
||||
|
||||
@@ -1032,6 +1032,8 @@ fn public_v0_2_9_pre_010_yellowstone_reconnect_snapshot_is_available_from_crate_
|
||||
let _gap_count = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::continuity_gap_count;
|
||||
let _duplicate_count = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::duplicate_update_count;
|
||||
let _replay_count = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::replay_attempt_count;
|
||||
let _delivery_count = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::replay_delivery_count;
|
||||
let _coverage_unproven_count = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::replay_coverage_unproven_count;
|
||||
let _requested = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::last_requested_from_slot;
|
||||
let _observed = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::last_observed_slot;
|
||||
let _terminal = ksp_onchain_transport_lib::YellowstoneGrpcSubscribeSnapshot::terminal_error_code;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/tests/release_completeness.rs
|
||||
// version: 45
|
||||
// version: 46
|
||||
|
||||
//! Release-level completeness canaries for staged HTTP and WebSocket Transport coverage.
|
||||
|
||||
@@ -1374,3 +1374,28 @@ fn release_v0_3_14_pre_003_ws_snapshot_source_reuses_actor_watch_without_second_
|
||||
assert!(!facade_source.contains("tokio_tungstenite::connect_async"));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_v0_3_14_pre_004_native_replay_evidence_never_conflates_attempt_delivery_and_coverage() {
|
||||
let stream_source = include_str!("../src/grpc_stream.rs");
|
||||
let stream_tests = include_str!("../unit_tests/grpc_stream.rs");
|
||||
for required in [
|
||||
"replay_attempt_count",
|
||||
"replay_delivery_count",
|
||||
"replay_coverage_unproven_count",
|
||||
"pending_replay_from_slot",
|
||||
"begin_replay",
|
||||
"abandon_pending_replay",
|
||||
"first_available > requested",
|
||||
"This is conservative delivery evidence only",
|
||||
"explicit lack of coverage proof",
|
||||
] {
|
||||
assert!(stream_source.contains(required), "missing pre.004 native replay evidence token: {required}");
|
||||
}
|
||||
assert!(stream_tests.contains("v0_3_14_pre_004_replay_acceptance_without_boundary_delivery_never_claims_coverage"));
|
||||
assert!(stream_tests.contains("yellowstone_reconnect_replays_from_last_observed_slot_and_counts_duplicate_identity"));
|
||||
assert!(stream_tests.contains("yellowstone_replay_info_proves_and_clamps_retention_gap_without_lossless_claim"));
|
||||
assert!(!stream_source.contains("replay_covered_count"));
|
||||
assert!(!stream_source.contains("replay_coverage_proven_count"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/grpc_stream.rs
|
||||
// version: 5
|
||||
// version: 6
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum FixtureMode {
|
||||
@@ -13,6 +13,7 @@ enum FixtureMode {
|
||||
Idle,
|
||||
ReconnectReplay,
|
||||
ReplayGap,
|
||||
ReplayAcceptedWithoutBoundary,
|
||||
ReconnectExhausted,
|
||||
}
|
||||
|
||||
@@ -153,6 +154,22 @@ impl yellowstone_grpc_proto::geyser::geyser_server::Geyser for FixtureGeyser {
|
||||
}
|
||||
}
|
||||
},
|
||||
FixtureMode::ReplayAcceptedWithoutBoundary => {
|
||||
if subscribe_call == 1 {
|
||||
assert_eq!(initial.from_slot, std::option::Option::None);
|
||||
let _ = outbound_tx.send(std::result::Result::Ok(slot_update(800))).await;
|
||||
} else {
|
||||
assert_eq!(subscribe_call, 2);
|
||||
assert_eq!(initial.from_slot, std::option::Option::Some(800));
|
||||
if outbound_tx.send(std::result::Result::Ok(slot_update(805))).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let half_close = inbound.message().await;
|
||||
if matches!(half_close, std::result::Result::Ok(std::option::Option::None)) {
|
||||
half_close_seen.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
},
|
||||
FixtureMode::ReconnectExhausted => {
|
||||
assert_eq!(subscribe_call, 1);
|
||||
let _ = outbound_tx.send(std::result::Result::Ok(slot_update(700))).await;
|
||||
@@ -191,7 +208,7 @@ impl yellowstone_grpc_proto::geyser::geyser_server::Geyser for FixtureGeyser {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let first_available = match self.mode {
|
||||
FixtureMode::ReconnectReplay | FixtureMode::ReconnectExhausted => std::option::Option::Some(400),
|
||||
FixtureMode::ReconnectReplay | FixtureMode::ReconnectExhausted | FixtureMode::ReplayAcceptedWithoutBoundary => std::option::Option::Some(400),
|
||||
FixtureMode::ReplayGap => std::option::Option::Some(505),
|
||||
_ => return std::result::Result::Err(tonic::Status::unimplemented("replay info is outside this fixture mode")),
|
||||
};
|
||||
@@ -595,6 +612,8 @@ async fn yellowstone_reconnect_replays_from_last_observed_slot_and_counts_duplic
|
||||
assert_eq!(snapshot.state(), crate::YellowstoneGrpcSubscribeState::Active);
|
||||
assert_eq!(snapshot.reconnect_count(), 1);
|
||||
assert_eq!(snapshot.replay_attempt_count(), 1);
|
||||
assert_eq!(snapshot.replay_delivery_count(), 1);
|
||||
assert_eq!(snapshot.replay_coverage_unproven_count(), 1);
|
||||
assert_eq!(snapshot.continuity_gap_count(), 0);
|
||||
assert_eq!(snapshot.duplicate_update_count(), 1);
|
||||
assert_eq!(snapshot.last_requested_from_slot(), std::option::Option::Some(500));
|
||||
@@ -627,6 +646,8 @@ async fn yellowstone_replay_info_proves_and_clamps_retention_gap_without_lossles
|
||||
let snapshot = session.snapshot();
|
||||
assert_eq!(snapshot.reconnect_count(), 1);
|
||||
assert_eq!(snapshot.replay_attempt_count(), 1);
|
||||
assert_eq!(snapshot.replay_delivery_count(), 1);
|
||||
assert_eq!(snapshot.replay_coverage_unproven_count(), 1);
|
||||
assert_eq!(snapshot.continuity_gap_count(), 1);
|
||||
assert_eq!(snapshot.duplicate_update_count(), 0);
|
||||
assert_eq!(snapshot.last_requested_from_slot(), std::option::Option::Some(505));
|
||||
@@ -635,6 +656,42 @@ async fn yellowstone_replay_info_proves_and_clamps_retention_gap_without_lossles
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn v0_3_14_pre_004_replay_acceptance_without_boundary_delivery_never_claims_coverage() {
|
||||
let server = FixtureServer::start(FixtureMode::ReplayAcceptedWithoutBoundary).await;
|
||||
let defaults = crate::YellowstoneGrpcSessionSettings::default();
|
||||
let settings = fixture_settings_with_reconnect(
|
||||
server.endpoint_url.as_str(),
|
||||
8,
|
||||
8,
|
||||
defaults.max_inbound_message_size_bytes(),
|
||||
defaults.max_outbound_message_size_bytes(),
|
||||
crate::YellowstoneGrpcReconnectSettings::new(3, std::time::Duration::from_millis(5), std::time::Duration::from_millis(20)),
|
||||
);
|
||||
let channel = crate::YellowstoneGrpcChannel::connect(&settings).await.expect("fixture channel must connect");
|
||||
let mut session = channel.open_standard_subscribe(initial_request()).await.expect("fixture Subscribe stream must open");
|
||||
let first = session.next_update().await.expect("first slot must decode").expect("first slot must be present");
|
||||
match first {
|
||||
crate::YellowstoneSubscribeUpdate::Slot(value) => assert_eq!(value.slot(), 800),
|
||||
_ => panic!("fixture must return first Slot"),
|
||||
}
|
||||
let resumed = session.next_update().await.expect("post-reconnect slot must decode").expect("post-reconnect slot must be present");
|
||||
match resumed {
|
||||
crate::YellowstoneSubscribeUpdate::Slot(value) => assert_eq!(value.slot(), 805),
|
||||
_ => panic!("fixture must return post-reconnect Slot"),
|
||||
}
|
||||
let snapshot = session.snapshot();
|
||||
assert_eq!(snapshot.reconnect_count(), 1);
|
||||
assert_eq!(snapshot.replay_attempt_count(), 1);
|
||||
assert_eq!(snapshot.replay_delivery_count(), 0);
|
||||
assert_eq!(snapshot.replay_coverage_unproven_count(), 1);
|
||||
assert_eq!(snapshot.continuity_gap_count(), 0);
|
||||
assert_eq!(snapshot.last_requested_from_slot(), std::option::Option::Some(800));
|
||||
assert_eq!(snapshot.last_observed_slot(), std::option::Option::Some(805));
|
||||
session.close().await.expect("coverage-unproven fixture must close cleanly");
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn yellowstone_reconnect_budget_exhaustion_is_terminal_and_safe() {
|
||||
let server = FixtureServer::start(FixtureMode::ReconnectExhausted).await;
|
||||
@@ -656,6 +713,8 @@ async fn yellowstone_reconnect_budget_exhaustion_is_terminal_and_safe() {
|
||||
assert_eq!(snapshot.state(), crate::YellowstoneGrpcSubscribeState::Failed);
|
||||
assert_eq!(snapshot.reconnect_count(), 0);
|
||||
assert_eq!(snapshot.replay_attempt_count(), 2);
|
||||
assert_eq!(snapshot.replay_delivery_count(), 0);
|
||||
assert_eq!(snapshot.replay_coverage_unproven_count(), 0);
|
||||
assert_eq!(snapshot.terminal_error_code(), std::option::Option::Some(crate::ERROR_CODE_GRPC_CHANNEL_FAILED));
|
||||
let rendered = format!("{error:?} {session:?}");
|
||||
assert!(!rendered.contains("GRPC-RECONNECT-SECRET-CANARY"));
|
||||
|
||||
Reference in New Issue
Block a user