406 lines
16 KiB
Rust
406 lines
16 KiB
Rust
// file: ks-pipeline/src/account_state_normalization.rs
|
|
// version: 1
|
|
|
|
//! Generic Solana account-observation to Core account-state normalization pipeline.
|
|
|
|
use sha2::Digest; // rust-rules: trait-import
|
|
|
|
/// Stable processor name used by generic account-state normalization.
|
|
pub const ACCOUNT_STATE_NORMALIZER_NAME: &str = "account_observation_to_core";
|
|
/// Current generic account-state normalizer implementation version.
|
|
pub const ACCOUNT_STATE_NORMALIZER_VERSION: &str = "1";
|
|
|
|
/// Bounded generic account-state normalization request.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct AccountStateNormalizationRequest {
|
|
/// Source observation selection.
|
|
pub selection: ks_store::AccountStateSelectionFilter,
|
|
/// Forces replacement even when the same processor version and input hash already succeeded.
|
|
pub force_replay: bool,
|
|
}
|
|
|
|
impl crate::AccountStateNormalizationRequest {
|
|
/// Builds a request bound to the active generic account-state normalizer identity.
|
|
pub fn new(
|
|
account_keys: std::vec::Vec<std::string::String>,
|
|
min_slot: std::option::Option<u64>,
|
|
max_slot: std::option::Option<u64>,
|
|
force_replay: bool,
|
|
limit: u32,
|
|
) -> ks_core::Result<Self> {
|
|
let selection_result = ks_store::AccountStateSelectionFilter::new(
|
|
crate::ACCOUNT_STATE_NORMALIZER_NAME,
|
|
crate::ACCOUNT_STATE_NORMALIZER_VERSION,
|
|
account_keys,
|
|
min_slot,
|
|
max_slot,
|
|
force_replay,
|
|
limit,
|
|
);
|
|
let selection = match selection_result {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
return std::result::Result::Ok(Self { selection, force_replay });
|
|
}
|
|
}
|
|
|
|
/// Summary returned by one generic account-state normalization campaign.
|
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
|
pub struct AccountStateNormalizationSummary {
|
|
/// Complete observations selected from N1.
|
|
pub selected: u64,
|
|
/// Core account states inserted or replaced in N2.
|
|
pub normalized: u64,
|
|
/// Inputs skipped because the same processor version and hash are already current.
|
|
pub skipped: u64,
|
|
/// Inputs that failed normalization or persistence.
|
|
pub failed: u64,
|
|
}
|
|
|
|
/// Executes one bounded replayable N1 account-observation to N2 Core account-state campaign.
|
|
pub async fn execute_account_state_normalization<S>(
|
|
store: &S,
|
|
request: &crate::AccountStateNormalizationRequest,
|
|
) -> ks_core::Result<crate::AccountStateNormalizationSummary>
|
|
where
|
|
S: ks_store::AccountStateStore + Sync,
|
|
{
|
|
if request.selection.processor_name != crate::ACCOUNT_STATE_NORMALIZER_NAME
|
|
|| request.selection.processor_version != crate::ACCOUNT_STATE_NORMALIZER_VERSION
|
|
{
|
|
return std::result::Result::Err(ks_core::Error::invalid_state(
|
|
"account-state selection processor identity does not match the active normalizer",
|
|
));
|
|
}
|
|
let mut effective_selection = request.selection.clone();
|
|
effective_selection.include_current = request.force_replay;
|
|
let observations_result =
|
|
store.list_account_observations_for_normalization(&effective_selection).await;
|
|
let observations = match observations_result {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let mut summary = crate::AccountStateNormalizationSummary {
|
|
selected: match u64::try_from(observations.len()) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
return std::result::Result::Err(ks_core::Error::invalid_state(format!(
|
|
"account-state selection length conversion failed: {error}"
|
|
)));
|
|
},
|
|
},
|
|
..crate::AccountStateNormalizationSummary::default()
|
|
};
|
|
tracing::info!(target: crate::TRACING_TARGET, action = "execute_account_state_normalization", selected = summary.selected, force_replay = request.force_replay, processor_name = crate::ACCOUNT_STATE_NORMALIZER_NAME, processor_version = crate::ACCOUNT_STATE_NORMALIZER_VERSION, "account-state normalization campaign started");
|
|
for observation in &observations {
|
|
let identity_result = ledger_identity_from_observation(observation);
|
|
let identity = match identity_result {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if !request.force_replay {
|
|
let current_result = store.is_account_state_current(&identity).await;
|
|
match current_result {
|
|
std::result::Result::Ok(true) => {
|
|
summary.skipped += 1;
|
|
continue;
|
|
},
|
|
std::result::Result::Ok(false) => {},
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
}
|
|
}
|
|
let state_result = core_state_from_observation(observation);
|
|
let state = match state_result {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
let failure_result =
|
|
persist_normalization_failure(store, observation.id, identity, &error).await;
|
|
if let std::result::Result::Err(persistence_error) = failure_result {
|
|
return std::result::Result::Err(persistence_error);
|
|
}
|
|
summary.failed += 1;
|
|
continue;
|
|
},
|
|
};
|
|
let bundle =
|
|
ks_store::AccountStatePersistenceBundle { ledger_identity: identity.clone(), state };
|
|
let persistence_result = store.persist_account_state(&bundle, request.force_replay).await;
|
|
match persistence_result {
|
|
std::result::Result::Ok(outcome) => {
|
|
if outcome.inserted_count > 0 || outcome.updated_count > 0 {
|
|
summary.normalized += 1;
|
|
} else {
|
|
summary.skipped += 1;
|
|
}
|
|
},
|
|
std::result::Result::Err(error) => {
|
|
let failure_result =
|
|
persist_normalization_failure(store, observation.id, identity, &error).await;
|
|
if let std::result::Result::Err(persistence_error) = failure_result {
|
|
return std::result::Result::Err(persistence_error);
|
|
}
|
|
summary.failed += 1;
|
|
},
|
|
}
|
|
}
|
|
tracing::info!(target: crate::TRACING_TARGET, action = "execute_account_state_normalization", selected = summary.selected, normalized = summary.normalized, skipped = summary.skipped, failed = summary.failed, force_replay = request.force_replay, processor_name = crate::ACCOUNT_STATE_NORMALIZER_NAME, processor_version = crate::ACCOUNT_STATE_NORMALIZER_VERSION, "account-state normalization campaign completed");
|
|
return std::result::Result::Ok(summary);
|
|
}
|
|
|
|
fn ledger_identity_from_observation(
|
|
observation: &ks_store::AccountObservationRow,
|
|
) -> ks_core::Result<ks_store::ProcessingLedgerIdentity> {
|
|
let hash = canonical_account_observation_hash(observation);
|
|
return ks_store::ProcessingLedgerIdentity::new(
|
|
ks_store::ACCOUNT_STATE_NORMALIZATION_STAGE,
|
|
crate::ACCOUNT_STATE_NORMALIZER_NAME,
|
|
crate::ACCOUNT_STATE_NORMALIZER_VERSION,
|
|
observation.observation_key.clone(),
|
|
hash,
|
|
);
|
|
}
|
|
|
|
fn canonical_account_observation_hash(
|
|
observation: &ks_store::AccountObservationRow,
|
|
) -> std::string::String {
|
|
let owner = match observation.owner.as_deref() {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => "",
|
|
};
|
|
let lamports = match observation.lamports.as_deref() {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => "",
|
|
};
|
|
let rent_epoch = match observation.rent_epoch.as_deref() {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => "",
|
|
};
|
|
let data_hash = match observation.data_hash.as_deref() {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => "",
|
|
};
|
|
let space = match observation.space {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => 0_i64,
|
|
};
|
|
let executable = match observation.executable {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => false,
|
|
};
|
|
let mut hasher = sha2::Sha256::new();
|
|
for value in [
|
|
observation.observation_key.as_str(),
|
|
observation.account_key.as_str(),
|
|
owner,
|
|
lamports,
|
|
rent_epoch,
|
|
data_hash,
|
|
] {
|
|
hasher.update(value.as_bytes());
|
|
hasher.update([0_u8]);
|
|
}
|
|
hasher.update(observation.context_slot.to_le_bytes());
|
|
hasher.update(space.to_le_bytes());
|
|
hasher.update([u8::from(executable)]);
|
|
let digest = hasher.finalize();
|
|
let mut output = std::string::String::with_capacity(64);
|
|
for byte in digest {
|
|
output.push_str(format!("{byte:02x}").as_str());
|
|
}
|
|
return output;
|
|
}
|
|
|
|
fn core_state_from_observation(
|
|
observation: &ks_store::AccountObservationRow,
|
|
) -> ks_core::Result<ks_store::CoreAccountStateInsert> {
|
|
let slot_result = u64::try_from(observation.context_slot);
|
|
let slot = match slot_result {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
return std::result::Result::Err(ks_core::Error::invalid_state(format!(
|
|
"account observation context slot is negative: {error}"
|
|
)));
|
|
},
|
|
};
|
|
let owner = match &observation.owner {
|
|
std::option::Option::Some(value) => value.clone(),
|
|
std::option::Option::None => {
|
|
return std::result::Result::Err(ks_core::Error::invalid_state(
|
|
"account observation owner is missing",
|
|
));
|
|
},
|
|
};
|
|
let lamports_result = parse_u64_text(observation.lamports.as_deref(), "lamports");
|
|
let lamports = match lamports_result {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let executable = match observation.executable {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => {
|
|
return std::result::Result::Err(ks_core::Error::invalid_state(
|
|
"account observation executable flag is missing",
|
|
));
|
|
},
|
|
};
|
|
let rent_epoch_result = parse_u64_text(observation.rent_epoch.as_deref(), "rent epoch");
|
|
let rent_epoch = match rent_epoch_result {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let space_sql = match observation.space {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => {
|
|
return std::result::Result::Err(ks_core::Error::invalid_state(
|
|
"account observation space is missing",
|
|
));
|
|
},
|
|
};
|
|
let space_result = u64::try_from(space_sql);
|
|
let space = match space_result {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
return std::result::Result::Err(ks_core::Error::invalid_state(format!(
|
|
"account observation space is negative: {error}"
|
|
)));
|
|
},
|
|
};
|
|
let data_base64 = match &observation.data_base64 {
|
|
std::option::Option::Some(value) => value.clone(),
|
|
std::option::Option::None => {
|
|
return std::result::Result::Err(ks_core::Error::invalid_state(
|
|
"account observation data is missing",
|
|
));
|
|
},
|
|
};
|
|
let data_hash = match &observation.data_hash {
|
|
std::option::Option::Some(value) => value.clone(),
|
|
std::option::Option::None => {
|
|
return std::result::Result::Err(ks_core::Error::invalid_state(
|
|
"account observation data hash is missing",
|
|
));
|
|
},
|
|
};
|
|
let state = ks_store::CoreAccountStateInsert {
|
|
source_observation_id: observation.id,
|
|
account_key: observation.account_key.clone(),
|
|
slot,
|
|
owner,
|
|
lamports,
|
|
executable,
|
|
rent_epoch,
|
|
space,
|
|
data_base64,
|
|
data_hash,
|
|
};
|
|
let validation_result = state.validate();
|
|
if let std::result::Result::Err(error) = validation_result {
|
|
return std::result::Result::Err(error);
|
|
}
|
|
return std::result::Result::Ok(state);
|
|
}
|
|
|
|
fn parse_u64_text(value: std::option::Option<&str>, label: &str) -> ks_core::Result<u64> {
|
|
let text = match value {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => {
|
|
return std::result::Result::Err(ks_core::Error::invalid_state(format!(
|
|
"account observation {label} is missing"
|
|
)));
|
|
},
|
|
};
|
|
let parse_result = text.parse::<u64>();
|
|
return match parse_result {
|
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
|
std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::invalid_state(
|
|
format!("account observation {label} is invalid: {error}"),
|
|
)),
|
|
};
|
|
}
|
|
|
|
async fn persist_normalization_failure<S>(
|
|
store: &S,
|
|
source_observation_id: i64,
|
|
identity: ks_store::ProcessingLedgerIdentity,
|
|
error: &ks_core::Error,
|
|
) -> ks_core::Result<()>
|
|
where
|
|
S: ks_store::AccountStateStore + Sync,
|
|
{
|
|
let message = bounded_error_message(error.to_string().as_str());
|
|
let failure = ks_store::AccountStateNormalizationFailure {
|
|
source_observation_id,
|
|
ledger_identity: identity,
|
|
error_code: "account_state_normalization_failed".to_string(),
|
|
error_message: message,
|
|
};
|
|
let persistence_result = store.mark_account_state_failed(&failure).await;
|
|
return match persistence_result {
|
|
std::result::Result::Ok(_outcome) => std::result::Result::Ok(()),
|
|
std::result::Result::Err(persistence_error) => std::result::Result::Err(persistence_error),
|
|
};
|
|
}
|
|
|
|
fn bounded_error_message(value: &str) -> std::string::String {
|
|
const MAX_ERROR_MESSAGE_CHARS: usize = 512;
|
|
return value.chars().take(MAX_ERROR_MESSAGE_CHARS).collect();
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#[test]
|
|
fn request_constructor_binds_active_processor_identity() {
|
|
let request_result = crate::AccountStateNormalizationRequest::new(
|
|
std::vec::Vec::new(),
|
|
std::option::Option::None,
|
|
std::option::Option::None,
|
|
true,
|
|
10,
|
|
);
|
|
let request = match request_result {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => panic!("unexpected request error: {error}"),
|
|
};
|
|
assert_eq!(request.selection.processor_name, crate::ACCOUNT_STATE_NORMALIZER_NAME);
|
|
assert_eq!(request.selection.processor_version, crate::ACCOUNT_STATE_NORMALIZER_VERSION);
|
|
assert!(request.selection.include_current);
|
|
assert!(request.force_replay);
|
|
}
|
|
|
|
#[test]
|
|
fn request_constructor_excludes_current_inputs_without_force_replay() {
|
|
let request_result = crate::AccountStateNormalizationRequest::new(
|
|
std::vec::Vec::new(),
|
|
std::option::Option::None,
|
|
std::option::Option::None,
|
|
false,
|
|
10,
|
|
);
|
|
let request = match request_result {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => panic!("unexpected request error: {error}"),
|
|
};
|
|
assert!(!request.selection.include_current);
|
|
assert!(!request.force_replay);
|
|
}
|
|
|
|
#[test]
|
|
fn unsigned_decimal_parser_accepts_full_u64_range() {
|
|
assert_eq!(
|
|
super::parse_u64_text(std::option::Option::Some("18446744073709551615"), "lamports"),
|
|
std::result::Result::Ok(u64::MAX)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unsigned_decimal_parser_rejects_negative_values() {
|
|
assert!(super::parse_u64_text(std::option::Option::Some("-1"), "lamports").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn error_messages_are_bounded() {
|
|
assert_eq!(super::bounded_error_message("x".repeat(600).as_str()).len(), 512);
|
|
}
|
|
}
|