v0.3.14-pre.008
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-worker-raw-transaction-ingest-lib/src/continuity.rs
|
||||
// version: 7
|
||||
// version: 8
|
||||
|
||||
/// Maximum number of slots admitted by one private continuity HTTP discovery window outside this module.
|
||||
pub(crate) const MAX_RAW_TRANSACTION_INGEST_CONTINUITY_DISCOVERY_WINDOW_SLOTS: u64 = MAX_RAW_TRANSACTION_INGEST_REPAIR_DISCOVERY_WINDOW_SLOTS;
|
||||
@@ -89,6 +89,7 @@ struct RawTransactionIngestCoverageRequirement {
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum RawTransactionIngestGapReason {
|
||||
HttpProducedBlockUnavailable,
|
||||
KnownReferenceMissing,
|
||||
SourceFailure,
|
||||
TransportOverflow,
|
||||
WebSocketReconnect,
|
||||
@@ -96,8 +97,14 @@ enum RawTransactionIngestGapReason {
|
||||
}
|
||||
|
||||
impl RawTransactionIngestGapReason {
|
||||
const ALL: [Self; 5] =
|
||||
[Self::HttpProducedBlockUnavailable, Self::SourceFailure, Self::TransportOverflow, Self::WebSocketReconnect, Self::YellowstoneRetention];
|
||||
const ALL: [Self; 6] = [
|
||||
Self::HttpProducedBlockUnavailable,
|
||||
Self::KnownReferenceMissing,
|
||||
Self::SourceFailure,
|
||||
Self::TransportOverflow,
|
||||
Self::WebSocketReconnect,
|
||||
Self::YellowstoneRetention,
|
||||
];
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -116,6 +123,15 @@ impl RawTransactionIngestGapState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Private supervisor decision for one terminal live-source loss after conservative continuity reconciliation.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum RawTransactionIngestSourceLossDecision {
|
||||
/// Remaining active sources explicitly preserve all configured target coverage and no known gap blocks continuity.
|
||||
Continue,
|
||||
/// Coverage is incomplete, a known gap remains open, or the source-loss proof is otherwise insufficient.
|
||||
Fault,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
struct RawTransactionIngestGapRange {
|
||||
end_slot: u64,
|
||||
@@ -267,7 +283,6 @@ impl crate::RawTransactionIngestWebSocketIncidentAnchor {
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
@@ -315,6 +330,84 @@ impl RawTransactionIngestGapLedger {
|
||||
return Self { gaps: std::vec::Vec::new(), network, next_gap_id: 1 };
|
||||
}
|
||||
|
||||
fn continuity_frontier(&self, processing_frontier_slot: std::option::Option<u64>) -> std::option::Option<u64> {
|
||||
let processing_frontier_slot = match processing_frontier_slot {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let earliest_open_gap_start = self.gaps.iter().filter(|gap| return gap.state.is_open()).map(|gap| return gap.range.start_slot()).min();
|
||||
let earliest_open_gap_start = match earliest_open_gap_start {
|
||||
std::option::Option::Some(value) if value <= processing_frontier_slot => value,
|
||||
std::option::Option::Some(_) | std::option::Option::None => return std::option::Option::Some(processing_frontier_slot),
|
||||
};
|
||||
return earliest_open_gap_start.checked_sub(1);
|
||||
}
|
||||
|
||||
fn has_open_gaps(&self) -> bool {
|
||||
return self.gaps.iter().any(|gap| return gap.state.is_open());
|
||||
}
|
||||
|
||||
fn record_gap(
|
||||
&mut self,
|
||||
source_key: [u8; 32],
|
||||
commitment: ksp_onchain_transport_lib::SolanaCommitment,
|
||||
coverage_requirement: crate::RawTransactionIngestCoverageScope,
|
||||
reason: RawTransactionIngestGapReason,
|
||||
range: RawTransactionIngestGapRange,
|
||||
) -> ksp_core_lib::Result<()> {
|
||||
for gap in &mut self.gaps {
|
||||
if !gap.state.is_open()
|
||||
|| gap.source_key != source_key
|
||||
|| gap.commitment != commitment
|
||||
|| gap.coverage_requirement != coverage_requirement
|
||||
|| gap.reason != reason
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let merged = match gap.range.try_merge(range) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if let std::option::Option::Some(merged) = merged {
|
||||
gap.range = merged;
|
||||
return self.validate_invariants();
|
||||
}
|
||||
}
|
||||
let open_gap_count = self.gaps.iter().filter(|gap| return gap.state.is_open()).count();
|
||||
if open_gap_count >= MAX_RAW_TRANSACTION_INGEST_OPEN_REPAIR_GAPS {
|
||||
return std::result::Result::Err(crate::runtime_error("continuity.open_gap_limit_exceeded"));
|
||||
}
|
||||
let gap_id = self.next_gap_id;
|
||||
self.next_gap_id = match self.next_gap_id.checked_add(1) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::runtime_error("continuity.gap_id_exhausted")),
|
||||
};
|
||||
self.gaps.push(RawTransactionIngestGap {
|
||||
commitment,
|
||||
coverage_requirement,
|
||||
gap_id: RawTransactionIngestGapId(gap_id),
|
||||
network: self.network.clone(),
|
||||
range,
|
||||
reason,
|
||||
source_key,
|
||||
state: RawTransactionIngestGapState::Pending,
|
||||
});
|
||||
return self.validate_invariants();
|
||||
}
|
||||
|
||||
fn reconcile_with_coverage_epochs(&mut self, coverage_epochs: &RawTransactionIngestCoverageEpochLedger) -> ksp_core_lib::Result<()> {
|
||||
for gap in &mut self.gaps {
|
||||
if !gap.state.is_open() {
|
||||
continue;
|
||||
}
|
||||
let requirement = RawTransactionIngestCoverageRequirement { commitment: gap.commitment, scope: gap.coverage_requirement.clone() };
|
||||
if coverage_epochs.redundant_relation_for_gap(gap.source_key, &requirement, gap.range).is_some() {
|
||||
gap.state = RawTransactionIngestGapState::Repaired;
|
||||
}
|
||||
}
|
||||
return self.validate_invariants();
|
||||
}
|
||||
|
||||
fn validate_invariants(&self) -> ksp_core_lib::Result<()> {
|
||||
if self.next_gap_id == 0 {
|
||||
return std::result::Result::Err(crate::runtime_error("continuity.gap_id_exhausted"));
|
||||
@@ -382,7 +475,7 @@ impl RawTransactionIngestGapLedger {
|
||||
}
|
||||
}
|
||||
}
|
||||
if RawTransactionIngestGapReason::ALL.len() != 5 || RawTransactionIngestGapState::ALL.len() != 4 {
|
||||
if RawTransactionIngestGapReason::ALL.len() != 6 || RawTransactionIngestGapState::ALL.len() != 4 {
|
||||
return std::result::Result::Err(crate::runtime_error("continuity.gap_catalog_invalid"));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
@@ -477,6 +570,21 @@ impl RawTransactionIngestTargetCoverage {
|
||||
return std::result::Result::Ok(value);
|
||||
}
|
||||
|
||||
fn is_covered_by_active_sources(
|
||||
&self,
|
||||
capabilities: &[crate::RawTransactionIngestContinuityCapabilityDescriptor],
|
||||
active_source_keys: &std::collections::BTreeSet<[u8; 32]>,
|
||||
) -> bool {
|
||||
return self.requirements.iter().all(|requirement| {
|
||||
return capabilities.iter().any(|capability| {
|
||||
if !active_source_keys.contains(&capability.source_key) || capability.commitment != requirement.commitment {
|
||||
return false;
|
||||
}
|
||||
return capability.source_scope.relation_to(&requirement.scope).is_some();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn include(
|
||||
&mut self,
|
||||
commitment: ksp_onchain_transport_lib::SolanaCommitment,
|
||||
@@ -518,6 +626,16 @@ impl RawTransactionIngestCoverageRange {
|
||||
const fn contains_gap(self, gap: RawTransactionIngestGapRange) -> bool {
|
||||
return self.start_slot <= gap.start_slot() && self.end_slot >= gap.end_slot();
|
||||
}
|
||||
|
||||
fn try_merge(self, other: Self) -> std::option::Option<Self> {
|
||||
let overlaps = self.start_slot <= other.end_slot && other.start_slot <= self.end_slot;
|
||||
let adjacent = self.end_slot.checked_add(1) == std::option::Option::Some(other.start_slot)
|
||||
|| other.end_slot.checked_add(1) == std::option::Option::Some(self.start_slot);
|
||||
if !overlaps && !adjacent {
|
||||
return std::option::Option::None;
|
||||
}
|
||||
return std::option::Option::Some(Self { end_slot: self.end_slot.max(other.end_slot), start_slot: self.start_slot.min(other.start_slot) });
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -569,6 +687,47 @@ impl RawTransactionIngestCoverageEpochLedger {
|
||||
return Self { epochs: std::vec::Vec::new(), next_epoch_id: 1 };
|
||||
}
|
||||
|
||||
fn record(
|
||||
&mut self,
|
||||
source_key: [u8; 32],
|
||||
commitment: ksp_onchain_transport_lib::SolanaCommitment,
|
||||
scope: crate::RawTransactionIngestCoverageScope,
|
||||
range: RawTransactionIngestCoverageRange,
|
||||
capabilities: &[crate::RawTransactionIngestContinuityCapabilityDescriptor],
|
||||
) -> ksp_core_lib::Result<()> {
|
||||
let matching_capability = capabilities.iter().find(|capability| return capability.source_key == source_key);
|
||||
let matching_capability = match matching_capability {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::runtime_error("continuity.coverage_epoch_source_unknown")),
|
||||
};
|
||||
if matching_capability.commitment != commitment || matching_capability.source_scope != scope {
|
||||
return std::result::Result::Err(crate::runtime_error("continuity.coverage_epoch_capability_mismatch"));
|
||||
}
|
||||
for epoch in &mut self.epochs {
|
||||
if epoch.source_key != source_key || epoch.commitment != commitment || epoch.scope != scope {
|
||||
continue;
|
||||
}
|
||||
if let std::option::Option::Some(merged) = epoch.range.try_merge(range) {
|
||||
epoch.range = merged;
|
||||
return self.validate_invariants(capabilities);
|
||||
}
|
||||
}
|
||||
if self.epochs.len() >= MAX_RAW_TRANSACTION_INGEST_COVERAGE_EPOCHS {
|
||||
return std::result::Result::Err(crate::runtime_error("continuity.coverage_epoch_inventory_invalid"));
|
||||
}
|
||||
let epoch_id = self.next_epoch_id;
|
||||
self.next_epoch_id = match self.next_epoch_id.checked_add(1) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::runtime_error("continuity.coverage_epoch_inventory_invalid")),
|
||||
};
|
||||
let epoch = match RawTransactionIngestCoverageEpoch::new(epoch_id, source_key, commitment, scope, range) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
self.epochs.push(epoch);
|
||||
return self.validate_invariants(capabilities);
|
||||
}
|
||||
|
||||
fn redundant_relation_for_gap(
|
||||
&self,
|
||||
target_source_key: [u8; 32],
|
||||
@@ -675,6 +834,111 @@ impl crate::RawTransactionIngestContinuityContracts {
|
||||
return std::result::Result::Ok(Self { capabilities, coverage_epochs, gap_ledger, target_coverage });
|
||||
}
|
||||
|
||||
/// Records one interval that a configured source has actually proven covered during this run.
|
||||
pub(crate) fn record_coverage_epoch(&mut self, source_key: [u8; 32], start_slot: u64, end_slot: u64) -> ksp_core_lib::Result<()> {
|
||||
let capability = self.capabilities.iter().find(|capability| return capability.source_key == source_key);
|
||||
let capability = match capability {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::runtime_error("continuity.coverage_epoch_source_unknown")),
|
||||
};
|
||||
let range = match RawTransactionIngestCoverageRange::new(start_slot, end_slot) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let commitment = capability.commitment;
|
||||
let scope = capability.source_scope.clone();
|
||||
if let std::result::Result::Err(error) = self.coverage_epochs.record(source_key, commitment, scope, range, self.capabilities.as_slice()) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return self.gap_ledger.reconcile_with_coverage_epochs(&self.coverage_epochs);
|
||||
}
|
||||
|
||||
/// Records one unresolved known-reference obligation as a single-slot continuity gap without blocking the processing frontier.
|
||||
pub(crate) fn record_known_reference_gap(&mut self, source_key: [u8; 32], slot: u64) -> ksp_core_lib::Result<()> {
|
||||
let capability = self.capabilities.iter().find(|capability| return capability.source_key == source_key);
|
||||
let capability = match capability {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::runtime_error("continuity.known_reference_source_unknown")),
|
||||
};
|
||||
let range = match RawTransactionIngestGapRange::new(slot, slot) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if let std::result::Result::Err(error) = self.gap_ledger.record_gap(
|
||||
source_key,
|
||||
capability.commitment,
|
||||
capability.source_scope.clone(),
|
||||
RawTransactionIngestGapReason::KnownReferenceMissing,
|
||||
range,
|
||||
) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return self.gap_ledger.reconcile_with_coverage_epochs(&self.coverage_epochs);
|
||||
}
|
||||
|
||||
/// Records one bounded run-local source-loss gap using the exact configured capability as its required coverage.
|
||||
pub(crate) fn record_source_loss_gap(&mut self, source_key: [u8; 32], start_slot: u64, end_slot: u64) -> ksp_core_lib::Result<()> {
|
||||
let capability = self.capabilities.iter().find(|capability| return capability.source_key == source_key);
|
||||
let capability = match capability {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(crate::runtime_error("continuity.source_loss_unknown_source")),
|
||||
};
|
||||
let range = match RawTransactionIngestGapRange::new(start_slot, end_slot) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return self.gap_ledger.record_gap(
|
||||
source_key,
|
||||
capability.commitment,
|
||||
capability.source_scope.clone(),
|
||||
RawTransactionIngestGapReason::SourceFailure,
|
||||
range,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns the gap-aware continuity frontier without conflating it with the processing frontier.
|
||||
///
|
||||
/// The processing frontier is only an upper bound: any known open continuity gap at or below it clamps the returned frontier to the slot immediately
|
||||
/// before that gap. A gap beginning at slot zero yields no continuity frontier.
|
||||
pub(crate) fn continuity_frontier(&self, processing_frontier_slot: std::option::Option<u64>) -> std::option::Option<u64> {
|
||||
return self.gap_ledger.continuity_frontier(processing_frontier_slot);
|
||||
}
|
||||
|
||||
/// Reconciles known gaps against already-proven coverage and decides whether one lost source may remain absent without stopping sibling sources.
|
||||
///
|
||||
/// Continuation requires all configured `TargetCoverage` requirements to remain covered by distinct currently active sources and requires the known
|
||||
/// gap ledger to be fully reconciled. The method never treats configuration alone as historical coverage evidence and never respawns a source.
|
||||
pub(crate) fn source_loss_decision(
|
||||
&mut self,
|
||||
lost_source_key: [u8; 32],
|
||||
active_source_keys: &[[u8; 32]],
|
||||
processing_frontier_slot: std::option::Option<u64>,
|
||||
) -> ksp_core_lib::Result<crate::RawTransactionIngestSourceLossDecision> {
|
||||
if !self.capabilities.iter().any(|capability| return capability.source_key == lost_source_key) {
|
||||
return std::result::Result::Err(crate::runtime_error("continuity.source_loss_unknown_source"));
|
||||
}
|
||||
let mut active = std::collections::BTreeSet::new();
|
||||
for source_key in active_source_keys {
|
||||
if *source_key == lost_source_key || !active.insert(*source_key) {
|
||||
return std::result::Result::Err(crate::runtime_error("continuity.source_loss_active_set_invalid"));
|
||||
}
|
||||
if !self.capabilities.iter().any(|capability| return capability.source_key == *source_key) {
|
||||
return std::result::Result::Err(crate::runtime_error("continuity.source_loss_active_source_unknown"));
|
||||
}
|
||||
}
|
||||
if let std::result::Result::Err(error) = self.gap_ledger.reconcile_with_coverage_epochs(&self.coverage_epochs) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let continuity_frontier = self.continuity_frontier(processing_frontier_slot);
|
||||
if self.gap_ledger.has_open_gaps() || continuity_frontier != processing_frontier_slot {
|
||||
return std::result::Result::Ok(crate::RawTransactionIngestSourceLossDecision::Fault);
|
||||
}
|
||||
if !self.target_coverage.is_covered_by_active_sources(self.capabilities.as_slice(), &active) {
|
||||
return std::result::Result::Ok(crate::RawTransactionIngestSourceLossDecision::Fault);
|
||||
}
|
||||
return std::result::Result::Ok(crate::RawTransactionIngestSourceLossDecision::Continue);
|
||||
}
|
||||
|
||||
/// Revalidates that the run-local contracts still correspond exactly to the caller-composed source count before tasks are spawned.
|
||||
pub(crate) fn validate_for_source_count(&self, expected_source_count: usize) -> ksp_core_lib::Result<()> {
|
||||
if self.capabilities.len() != expected_source_count || self.target_coverage.requirements.is_empty() {
|
||||
|
||||
Reference in New Issue
Block a user