0.5.1-pre.002
This commit is contained in:
1145
ks-store/src/postgres/migrations.rs
Normal file
1145
ks-store/src/postgres/migrations.rs
Normal file
File diff suppressed because it is too large
Load Diff
52
ks-store/src/postgres/query.rs
Normal file
52
ks-store/src/postgres/query.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
// file: ks-store/src/postgres/query.rs
|
||||
// version: 3
|
||||
|
||||
//! PostgreSQL query modules.
|
||||
|
||||
mod core_extraction_queries;
|
||||
mod core_queries;
|
||||
mod decode_pipeline_queries;
|
||||
mod health_queries;
|
||||
mod raw_queries;
|
||||
mod replay_candidate_queries;
|
||||
mod table_diagnostics_queries;
|
||||
|
||||
pub(in crate::postgres) use self::core_extraction_queries::is_core_extraction_current;
|
||||
pub(in crate::postgres) use self::core_extraction_queries::list_raw_transactions_for_core_extraction;
|
||||
pub(in crate::postgres) use self::core_extraction_queries::mark_core_extraction_failed;
|
||||
pub(in crate::postgres) use self::core_extraction_queries::persist_core_extraction;
|
||||
pub(in crate::postgres) use self::core_queries::apply_core_store_schema;
|
||||
pub(in crate::postgres) use self::core_queries::insert_core_account_keys;
|
||||
pub(in crate::postgres) use self::core_queries::insert_core_balance_changes;
|
||||
pub(in crate::postgres) use self::core_queries::insert_core_inner_instructions;
|
||||
pub(in crate::postgres) use self::core_queries::insert_core_instructions;
|
||||
pub(in crate::postgres) use self::core_queries::insert_core_logs;
|
||||
pub(in crate::postgres) use self::core_queries::insert_core_transaction;
|
||||
pub(in crate::postgres) use self::core_queries::list_core_instruction_replay_inputs;
|
||||
pub(in crate::postgres) use self::core_queries::list_core_instructions_for_replay;
|
||||
pub(in crate::postgres) use self::core_queries::update_core_instruction_lifecycle;
|
||||
pub(in crate::postgres) use self::decode_pipeline_queries::apply_decode_store_schema;
|
||||
pub(in crate::postgres) use self::decode_pipeline_queries::is_decode_current;
|
||||
pub(in crate::postgres) use self::decode_pipeline_queries::list_decode_coverage_summary;
|
||||
pub(in crate::postgres) use self::decode_pipeline_queries::list_decode_inputs;
|
||||
pub(in crate::postgres) use self::decode_pipeline_queries::list_materialized_events;
|
||||
pub(in crate::postgres) use self::decode_pipeline_queries::mark_decode_failed;
|
||||
pub(in crate::postgres) use self::decode_pipeline_queries::persist_decode_coverage_declarations;
|
||||
pub(in crate::postgres) use self::decode_pipeline_queries::persist_decode_result;
|
||||
pub(in crate::postgres) use self::decode_pipeline_queries::persist_materialization_result;
|
||||
pub(in crate::postgres) use self::health_queries::load_current_schema;
|
||||
pub(in crate::postgres) use self::health_queries::load_latest_migration_version;
|
||||
pub(in crate::postgres) use self::health_queries::load_migration_table_name;
|
||||
pub(in crate::postgres) use self::health_queries::load_server_version;
|
||||
pub(in crate::postgres) use self::health_queries::run_health_check;
|
||||
pub(in crate::postgres) use self::raw_queries::apply_raw_store_schema;
|
||||
pub(in crate::postgres) use self::raw_queries::has_raw_transaction_signature;
|
||||
pub(in crate::postgres) use self::raw_queries::has_transaction_observation_key;
|
||||
pub(in crate::postgres) use self::raw_queries::insert_raw_transaction;
|
||||
pub(in crate::postgres) use self::raw_queries::insert_transaction_observation;
|
||||
pub(in crate::postgres) use self::raw_queries::update_raw_payload_lifecycle;
|
||||
pub(in crate::postgres) use self::replay_candidate_queries::list_replay_entity_summaries;
|
||||
pub(in crate::postgres) use self::replay_candidate_queries::list_replay_program_summaries;
|
||||
pub(in crate::postgres) use self::replay_candidate_queries::list_replay_transaction_candidates;
|
||||
pub(in crate::postgres) use self::table_diagnostics_queries::load_table_statistics;
|
||||
pub(in crate::postgres) use self::table_diagnostics_queries::table_exists;
|
||||
1003
ks-store/src/postgres/query/core_extraction_queries.rs
Normal file
1003
ks-store/src/postgres/query/core_extraction_queries.rs
Normal file
File diff suppressed because it is too large
Load Diff
1219
ks-store/src/postgres/query/core_queries.rs
Normal file
1219
ks-store/src/postgres/query/core_queries.rs
Normal file
File diff suppressed because it is too large
Load Diff
1340
ks-store/src/postgres/query/decode_pipeline_queries.rs
Normal file
1340
ks-store/src/postgres/query/decode_pipeline_queries.rs
Normal file
File diff suppressed because it is too large
Load Diff
77
ks-store/src/postgres/query/health_queries.rs
Normal file
77
ks-store/src/postgres/query/health_queries.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
// file: ks-store/src/postgres/query/health_queries.rs
|
||||
// version: 2
|
||||
|
||||
//! PostgreSQL health and diagnostic SQL queries.
|
||||
|
||||
pub(in crate::postgres) async fn run_health_check(pool: &sqlx::PgPool) -> ks_core::Result<()> {
|
||||
let query_result = sqlx::query_scalar::<sqlx::Postgres, i32>("SELECT 1").fetch_one(pool).await;
|
||||
return match query_result {
|
||||
std::result::Result::Ok(_value) => std::result::Result::Ok(()),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::db(format!(
|
||||
"postgres healthcheck failed: {error}"
|
||||
))),
|
||||
};
|
||||
}
|
||||
|
||||
pub(in crate::postgres) async fn load_current_schema(
|
||||
pool: &sqlx::PgPool,
|
||||
) -> ks_core::Result<std::string::String> {
|
||||
let query_result =
|
||||
sqlx::query_scalar::<sqlx::Postgres, std::string::String>("SELECT current_schema()")
|
||||
.fetch_one(pool)
|
||||
.await;
|
||||
return match query_result {
|
||||
std::result::Result::Ok(schema) => std::result::Result::Ok(schema),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::db(format!(
|
||||
"postgres current schema query failed: {error}"
|
||||
))),
|
||||
};
|
||||
}
|
||||
|
||||
pub(in crate::postgres) async fn load_server_version(
|
||||
pool: &sqlx::PgPool,
|
||||
) -> ks_core::Result<std::string::String> {
|
||||
let query_result =
|
||||
sqlx::query_scalar::<sqlx::Postgres, std::string::String>("SELECT version()")
|
||||
.fetch_one(pool)
|
||||
.await;
|
||||
return match query_result {
|
||||
std::result::Result::Ok(version) => std::result::Result::Ok(version),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::db(format!(
|
||||
"postgres version query failed: {error}"
|
||||
))),
|
||||
};
|
||||
}
|
||||
|
||||
pub(in crate::postgres) async fn load_migration_table_name(
|
||||
pool: &sqlx::PgPool,
|
||||
) -> ks_core::Result<std::option::Option<std::string::String>> {
|
||||
let query_result =
|
||||
sqlx::query_scalar::<sqlx::Postgres, std::option::Option<std::string::String>>(
|
||||
"SELECT to_regclass('_sqlx_migrations')::text",
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await;
|
||||
return match query_result {
|
||||
std::result::Result::Ok(table_name) => std::result::Result::Ok(table_name),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::db(format!(
|
||||
"postgres migration table query failed: {error}"
|
||||
))),
|
||||
};
|
||||
}
|
||||
|
||||
pub(in crate::postgres) async fn load_latest_migration_version(
|
||||
pool: &sqlx::PgPool,
|
||||
) -> ks_core::Result<std::option::Option<std::string::String>> {
|
||||
let query_result = sqlx::query_scalar::<sqlx::Postgres, std::option::Option<std::string::String>>(
|
||||
"SELECT version::text FROM _sqlx_migrations WHERE success = true ORDER BY version DESC LIMIT 1",
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await;
|
||||
return match query_result {
|
||||
std::result::Result::Ok(version) => std::result::Result::Ok(version.flatten()),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::db(format!(
|
||||
"postgres latest migration query failed: {error}"
|
||||
))),
|
||||
};
|
||||
}
|
||||
506
ks-store/src/postgres/query/raw_queries.rs
Normal file
506
ks-store/src/postgres/query/raw_queries.rs
Normal file
@@ -0,0 +1,506 @@
|
||||
// file: ks-store/src/postgres/query/raw_queries.rs
|
||||
// version: 3
|
||||
|
||||
//! PostgreSQL canonical transaction and acquisition observation SQL queries.
|
||||
|
||||
pub(in crate::postgres) async fn apply_raw_store_schema(
|
||||
pool: &sqlx::PgPool,
|
||||
) -> ks_core::Result<()> {
|
||||
let validation_result = crate::validate_raw_store_table_names();
|
||||
if let std::result::Result::Err(error) = validation_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let transaction_result = pool.begin().await;
|
||||
let mut transaction = match transaction_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::db(format!(
|
||||
"postgres canonical raw store schema transaction failed: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
let lock_result = sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||||
.bind(crate::STORE_SCHEMA_ADVISORY_LOCK_ID)
|
||||
.execute(&mut *transaction)
|
||||
.await;
|
||||
if let std::result::Result::Err(error) = lock_result {
|
||||
return std::result::Result::Err(ks_core::Error::db(format!(
|
||||
"postgres canonical raw store schema advisory lock failed: {error}"
|
||||
)));
|
||||
}
|
||||
for statement in crate::raw_store_schema_statements() {
|
||||
let execution_result = sqlx::query(statement).execute(&mut *transaction).await;
|
||||
if let std::result::Result::Err(error) = execution_result {
|
||||
return std::result::Result::Err(ks_core::Error::db(format!(
|
||||
"postgres canonical raw store schema statement failed: {error}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let commit_result = transaction.commit().await;
|
||||
if let std::result::Result::Err(error) = commit_result {
|
||||
return std::result::Result::Err(ks_core::Error::db(format!(
|
||||
"postgres canonical raw store schema commit failed: {error}"
|
||||
)));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
pub(in crate::postgres) async fn has_raw_transaction_signature(
|
||||
pool: &sqlx::PgPool,
|
||||
signature: &str,
|
||||
) -> ks_core::Result<bool> {
|
||||
let validation_result = crate::postgres::query::raw_queries::validate_required_text(
|
||||
signature,
|
||||
"canonical raw transaction signature must not be empty",
|
||||
);
|
||||
if let std::result::Result::Err(error) = validation_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let query_result = sqlx::query_scalar::<sqlx::Postgres, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM kb_sol_raw_transactions WHERE signature = $1)",
|
||||
)
|
||||
.bind(signature)
|
||||
.fetch_one(pool)
|
||||
.await;
|
||||
return match query_result {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::db(format!(
|
||||
"postgres canonical transaction signature lookup failed: {error}"
|
||||
))),
|
||||
};
|
||||
}
|
||||
|
||||
pub(in crate::postgres) async fn has_transaction_observation_key(
|
||||
pool: &sqlx::PgPool,
|
||||
observation_key: &str,
|
||||
) -> ks_core::Result<bool> {
|
||||
let validation_result = crate::postgres::query::raw_queries::validate_required_text(
|
||||
observation_key,
|
||||
"transaction observation key must not be empty",
|
||||
);
|
||||
if let std::result::Result::Err(error) = validation_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let query_result = sqlx::query_scalar::<sqlx::Postgres, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM kb_sol_obs_transaction_observations WHERE observation_key = $1)",
|
||||
)
|
||||
.bind(observation_key)
|
||||
.fetch_one(pool)
|
||||
.await;
|
||||
return match query_result {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::db(format!(
|
||||
"postgres transaction observation lookup failed: {error}"
|
||||
))),
|
||||
};
|
||||
}
|
||||
|
||||
pub(in crate::postgres) async fn insert_raw_transaction(
|
||||
pool: &sqlx::PgPool,
|
||||
input: &crate::RawTransactionInsert,
|
||||
) -> ks_core::Result<crate::InsertOutcome> {
|
||||
let slot_result = crate::postgres::query::raw_queries::sql_slot_from_u64(input.slot);
|
||||
let slot = match slot_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let version_result = i32::try_from(input.canonical_format_version);
|
||||
let canonical_format_version = match version_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::db(format!(
|
||||
"canonical transaction format version does not fit into SQL INTEGER: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
let query_result = sqlx::query_scalar::<sqlx::Postgres, i64>(
|
||||
"INSERT INTO kb_sol_raw_transactions (signature, slot, canonical_json, canonical_json_hash, canonical_format_version, retention_state, processing_state) VALUES ($1, $2, $3, $4, $5, 'full', 'received') ON CONFLICT (signature) DO NOTHING RETURNING id",
|
||||
)
|
||||
.bind(input.signature.as_str())
|
||||
.bind(slot)
|
||||
.bind(&input.canonical_json)
|
||||
.bind(input.canonical_json_hash.as_deref())
|
||||
.bind(canonical_format_version)
|
||||
.fetch_optional(pool)
|
||||
.await;
|
||||
return match query_result {
|
||||
std::result::Result::Ok(std::option::Option::Some(_id)) => {
|
||||
std::result::Result::Ok(crate::InsertOutcome::new(1, 0, 0))
|
||||
},
|
||||
std::result::Result::Ok(std::option::Option::None) => {
|
||||
std::result::Result::Ok(crate::InsertOutcome::new(0, 0, 1))
|
||||
},
|
||||
std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::db(format!(
|
||||
"postgres canonical transaction insert failed: {error}"
|
||||
))),
|
||||
};
|
||||
}
|
||||
|
||||
pub(in crate::postgres) async fn insert_transaction_observation(
|
||||
pool: &sqlx::PgPool,
|
||||
input: &crate::TransactionObservationInsert,
|
||||
) -> ks_core::Result<crate::InsertOutcome> {
|
||||
let slot_result = crate::postgres::query::raw_queries::optional_sql_slot_from_u64(input.slot);
|
||||
let slot = match slot_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let payload_size_result =
|
||||
crate::postgres::query::raw_queries::optional_sql_bigint_from_u64(input.payload_size_bytes);
|
||||
let payload_size_bytes = match payload_size_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let origin =
|
||||
crate::postgres::query::raw_queries::transaction_observation_origin_to_sql(input.origin);
|
||||
let status =
|
||||
crate::postgres::query::raw_queries::transaction_observation_status_to_sql(input.status);
|
||||
let query_result = sqlx::query_scalar::<sqlx::Postgres, i64>(
|
||||
"INSERT INTO kb_sol_obs_transaction_observations (raw_transaction_id, observation_key, signature, slot, provider, endpoint_code, protocol, acquisition_method, origin, commitment, capture_session_id, filter_code, detected_at, received_at, normalized_at, payload_size_bytes, source_payload_hash, status, error_code, error_message) VALUES (COALESCE($1, (SELECT id FROM kb_sol_raw_transactions WHERE signature = $3 LIMIT 1)), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) ON CONFLICT (observation_key) DO NOTHING RETURNING id",
|
||||
)
|
||||
.bind(input.raw_transaction_id)
|
||||
.bind(input.observation_key.as_str())
|
||||
.bind(input.signature.as_deref())
|
||||
.bind(slot)
|
||||
.bind(input.provider.as_str())
|
||||
.bind(input.endpoint_code.as_deref())
|
||||
.bind(input.protocol.as_str())
|
||||
.bind(input.acquisition_method.as_str())
|
||||
.bind(origin)
|
||||
.bind(input.commitment.as_deref())
|
||||
.bind(input.capture_session_id.as_deref())
|
||||
.bind(input.filter_code.as_deref())
|
||||
.bind(input.detected_at.as_ref())
|
||||
.bind(input.received_at)
|
||||
.bind(input.normalized_at.as_ref())
|
||||
.bind(payload_size_bytes)
|
||||
.bind(input.source_payload_hash.as_deref())
|
||||
.bind(status)
|
||||
.bind(input.error_code.as_deref())
|
||||
.bind(input.error_message.as_deref())
|
||||
.fetch_optional(pool)
|
||||
.await;
|
||||
return match query_result {
|
||||
std::result::Result::Ok(std::option::Option::Some(_id)) => {
|
||||
std::result::Result::Ok(crate::InsertOutcome::new(1, 0, 0))
|
||||
},
|
||||
std::result::Result::Ok(std::option::Option::None) => {
|
||||
std::result::Result::Ok(crate::InsertOutcome::new(0, 0, 1))
|
||||
},
|
||||
std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::db(format!(
|
||||
"postgres transaction observation insert failed: {error}"
|
||||
))),
|
||||
};
|
||||
}
|
||||
|
||||
pub(in crate::postgres) async fn update_raw_payload_lifecycle(
|
||||
pool: &sqlx::PgPool,
|
||||
mark: &crate::RawPayloadLifecycleMark,
|
||||
) -> ks_core::Result<crate::InsertOutcome> {
|
||||
if mark.raw_table_name != crate::RAW_TRANSACTIONS_TABLE_NAME {
|
||||
return std::result::Result::Err(ks_core::Error::db(
|
||||
"raw lifecycle table name is not supported by the PostgreSQL canonical raw store",
|
||||
));
|
||||
}
|
||||
return crate::postgres::query::raw_queries::update_raw_transaction_lifecycle(pool, mark).await;
|
||||
}
|
||||
|
||||
fn raw_retention_state_to_sql(state: crate::RawPayloadRetentionState) -> &'static str {
|
||||
return match state {
|
||||
crate::RawPayloadRetentionState::Full => "full",
|
||||
crate::RawPayloadRetentionState::Compacted => "compacted",
|
||||
crate::RawPayloadRetentionState::Archived => "archived",
|
||||
crate::RawPayloadRetentionState::Purged => "purged",
|
||||
};
|
||||
}
|
||||
|
||||
fn raw_processing_state_to_sql(state: crate::RawPayloadProcessingState) -> &'static str {
|
||||
return match state {
|
||||
crate::RawPayloadProcessingState::Received => "received",
|
||||
crate::RawPayloadProcessingState::CoreExtracted => "core_extracted",
|
||||
crate::RawPayloadProcessingState::Decoded => "decoded",
|
||||
crate::RawPayloadProcessingState::Materialized => "materialized",
|
||||
crate::RawPayloadProcessingState::Failed => "failed",
|
||||
};
|
||||
}
|
||||
|
||||
fn transaction_observation_origin_to_sql(
|
||||
origin: crate::TransactionObservationOrigin,
|
||||
) -> &'static str {
|
||||
return match origin {
|
||||
crate::TransactionObservationOrigin::Live => "live",
|
||||
crate::TransactionObservationOrigin::Backfill => "backfill",
|
||||
crate::TransactionObservationOrigin::Replay => "replay",
|
||||
crate::TransactionObservationOrigin::Repair => "repair",
|
||||
crate::TransactionObservationOrigin::Migration => "migration",
|
||||
};
|
||||
}
|
||||
|
||||
fn transaction_observation_status_to_sql(
|
||||
status: crate::TransactionObservationStatus,
|
||||
) -> &'static str {
|
||||
return match status {
|
||||
crate::TransactionObservationStatus::Detected => "detected",
|
||||
crate::TransactionObservationStatus::Received => "received",
|
||||
crate::TransactionObservationStatus::Normalized => "normalized",
|
||||
crate::TransactionObservationStatus::Persisted => "persisted",
|
||||
crate::TransactionObservationStatus::Failed => "failed",
|
||||
crate::TransactionObservationStatus::Missing => "missing",
|
||||
};
|
||||
}
|
||||
|
||||
fn sql_slot_from_u64(slot: u64) -> ks_core::Result<i64> {
|
||||
let conversion_result = i64::try_from(slot);
|
||||
return match conversion_result {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::db(format!(
|
||||
"Solana slot does not fit into PostgreSQL BIGINT: {error}"
|
||||
))),
|
||||
};
|
||||
}
|
||||
|
||||
fn optional_sql_slot_from_u64(
|
||||
slot: std::option::Option<u64>,
|
||||
) -> ks_core::Result<std::option::Option<i64>> {
|
||||
return crate::postgres::query::raw_queries::optional_sql_bigint_from_u64(slot);
|
||||
}
|
||||
|
||||
fn optional_sql_bigint_from_u64(
|
||||
value: std::option::Option<u64>,
|
||||
) -> ks_core::Result<std::option::Option<i64>> {
|
||||
return match value {
|
||||
std::option::Option::Some(raw_value) => {
|
||||
let conversion_result = i64::try_from(raw_value);
|
||||
match conversion_result {
|
||||
std::result::Result::Ok(converted) => {
|
||||
std::result::Result::Ok(std::option::Option::Some(converted))
|
||||
},
|
||||
std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::db(
|
||||
format!("unsigned value does not fit into PostgreSQL BIGINT: {error}"),
|
||||
)),
|
||||
}
|
||||
},
|
||||
std::option::Option::None => std::result::Result::Ok(std::option::Option::None),
|
||||
};
|
||||
}
|
||||
|
||||
fn validate_required_text(value: &str, message: &str) -> ks_core::Result<()> {
|
||||
if value.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::db(message));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
async fn update_raw_transaction_lifecycle(
|
||||
pool: &sqlx::PgPool,
|
||||
mark: &crate::RawPayloadLifecycleMark,
|
||||
) -> ks_core::Result<crate::InsertOutcome> {
|
||||
let retention_state =
|
||||
crate::postgres::query::raw_queries::raw_retention_state_to_sql(mark.retention_state);
|
||||
let processing_state =
|
||||
crate::postgres::query::raw_queries::raw_processing_state_to_sql(mark.processing_state);
|
||||
let query_result = sqlx::query(
|
||||
"UPDATE kb_sol_raw_transactions SET retention_state = $1, processing_state = $2, lifecycle_reason = $3, updated_at = NOW() WHERE signature = $4",
|
||||
)
|
||||
.bind(retention_state)
|
||||
.bind(processing_state)
|
||||
.bind(mark.reason.as_deref())
|
||||
.bind(mark.raw_row_key.as_str())
|
||||
.execute(pool)
|
||||
.await;
|
||||
return crate::postgres::query::raw_queries::outcome_from_update_result(
|
||||
query_result,
|
||||
"postgres canonical raw transaction lifecycle update failed",
|
||||
);
|
||||
}
|
||||
|
||||
fn outcome_from_update_result(
|
||||
query_result: std::result::Result<sqlx::postgres::PgQueryResult, sqlx::Error>,
|
||||
error_prefix: &str,
|
||||
) -> ks_core::Result<crate::InsertOutcome> {
|
||||
return match query_result {
|
||||
std::result::Result::Ok(result) => {
|
||||
let rows_affected = result.rows_affected();
|
||||
if rows_affected == 0 {
|
||||
return std::result::Result::Ok(crate::InsertOutcome::new(0, 0, 1));
|
||||
}
|
||||
std::result::Result::Ok(crate::InsertOutcome::new(0, rows_affected, 0))
|
||||
},
|
||||
std::result::Result::Err(error) => {
|
||||
std::result::Result::Err(ks_core::Error::db(format!("{error_prefix}: {error}")))
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn retention_state_serializes_to_lower_snake_case() {
|
||||
let value = crate::postgres::query::raw_queries::raw_retention_state_to_sql(
|
||||
crate::RawPayloadRetentionState::Compacted,
|
||||
);
|
||||
assert_eq!(value, "compacted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn processing_state_serializes_to_lower_snake_case() {
|
||||
let value = crate::postgres::query::raw_queries::raw_processing_state_to_sql(
|
||||
crate::RawPayloadProcessingState::CoreExtracted,
|
||||
);
|
||||
assert_eq!(value, "core_extracted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observation_origin_serializes_to_lower_snake_case() {
|
||||
let value = crate::postgres::query::raw_queries::transaction_observation_origin_to_sql(
|
||||
crate::TransactionObservationOrigin::Backfill,
|
||||
);
|
||||
assert_eq!(value, "backfill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observation_status_serializes_to_lower_snake_case() {
|
||||
let value = crate::postgres::query::raw_queries::transaction_observation_status_to_sql(
|
||||
crate::TransactionObservationStatus::Normalized,
|
||||
);
|
||||
assert_eq!(value, "normalized");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sql_slot_rejects_values_above_bigint() {
|
||||
let result = crate::postgres::query::raw_queries::sql_slot_from_u64(u64::MAX);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn optional_postgres_canonical_store_roundtrip_from_env() {
|
||||
let database_url = match std::env::var("KB_POSTGRES_TEST_URL") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_error) => return,
|
||||
};
|
||||
let _postgres_guard = crate::postgres::test_serial::postgres_test_guard().await;
|
||||
let options_result = crate::PostgresStoreOptions::new(database_url, 1, 5000, false);
|
||||
let options = match options_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("unexpected options error: {error}"),
|
||||
};
|
||||
let store_result = crate::PostgresStore::connect(options).await;
|
||||
let store = match store_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("unexpected connect error: {error}"),
|
||||
};
|
||||
let schema_result = store.initialize_raw_store_schema().await;
|
||||
if let std::result::Result::Err(error) = schema_result {
|
||||
panic!("unexpected schema error: {error}");
|
||||
}
|
||||
let signature = test_signature();
|
||||
let raw_input_result = crate::RawTransactionInsert::new(
|
||||
signature.clone(),
|
||||
1,
|
||||
serde_json::json!({"source": "test"}),
|
||||
1,
|
||||
);
|
||||
let raw_input = match raw_input_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("unexpected raw input error: {error}"),
|
||||
};
|
||||
let first_result =
|
||||
crate::RawTransactionStore::insert_raw_transaction(&store, &raw_input).await;
|
||||
let first_outcome = match first_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("unexpected first insert error: {error}"),
|
||||
};
|
||||
assert_eq!(first_outcome.inserted_count, 1);
|
||||
let duplicate_result =
|
||||
crate::RawTransactionStore::insert_raw_transaction(&store, &raw_input).await;
|
||||
let duplicate_outcome = match duplicate_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("unexpected duplicate insert error: {error}"),
|
||||
};
|
||||
assert_eq!(duplicate_outcome.skipped_count, 1);
|
||||
let has_result = crate::RawTransactionStore::has_raw_transaction_signature(
|
||||
&store,
|
||||
&ks_lib::MdSignature(signature.clone()),
|
||||
)
|
||||
.await;
|
||||
let has_signature = match has_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("unexpected signature lookup error: {error}"),
|
||||
};
|
||||
assert!(has_signature);
|
||||
let observation_key = std::string::String::from("test:http:") + signature.as_str();
|
||||
let observation_result = crate::TransactionObservationInsert::new(
|
||||
observation_key.clone(),
|
||||
"test_provider",
|
||||
"solana_http",
|
||||
"getTransaction",
|
||||
crate::TransactionObservationOrigin::Backfill,
|
||||
chrono::Utc::now(),
|
||||
);
|
||||
let observation = match observation_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("unexpected observation error: {error}"),
|
||||
};
|
||||
let identity_result =
|
||||
observation.with_transaction_identity(signature.clone(), std::option::Option::Some(1));
|
||||
let observation_with_identity = match identity_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("unexpected identity error: {error}"),
|
||||
};
|
||||
let observation_insert_result = crate::RawTransactionStore::insert_transaction_observation(
|
||||
&store,
|
||||
&observation_with_identity,
|
||||
)
|
||||
.await;
|
||||
let observation_outcome = match observation_insert_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
panic!("unexpected observation insert error: {error}")
|
||||
},
|
||||
};
|
||||
assert_eq!(observation_outcome.inserted_count, 1);
|
||||
let observation_lookup_result =
|
||||
crate::RawTransactionStore::has_transaction_observation_key(
|
||||
&store,
|
||||
observation_key.as_str(),
|
||||
)
|
||||
.await;
|
||||
let has_observation = match observation_lookup_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
panic!("unexpected observation lookup error: {error}")
|
||||
},
|
||||
};
|
||||
assert!(has_observation);
|
||||
let mark_result = crate::RawPayloadLifecycleMark::new(
|
||||
crate::RAW_TRANSACTIONS_TABLE_NAME,
|
||||
signature,
|
||||
crate::RawPayloadRetentionState::Full,
|
||||
crate::RawPayloadProcessingState::CoreExtracted,
|
||||
std::option::Option::Some(std::string::String::from("test extraction")),
|
||||
);
|
||||
let mark = match mark_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("unexpected mark error: {error}"),
|
||||
};
|
||||
let mark_outcome_result =
|
||||
crate::RawTransactionStore::mark_raw_payload_lifecycle(&store, &mark).await;
|
||||
let mark_outcome = match mark_outcome_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("unexpected lifecycle update error: {error}"),
|
||||
};
|
||||
assert_eq!(mark_outcome.updated_count, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
fn test_signature() -> std::string::String {
|
||||
let now_result = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH);
|
||||
let duration = match now_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_error) => {
|
||||
return std::string::String::from("test_signature_fallback");
|
||||
},
|
||||
};
|
||||
return format!("test_signature_{}", duration.as_nanos());
|
||||
}
|
||||
}
|
||||
474
ks-store/src/postgres/query/replay_candidate_queries.rs
Normal file
474
ks-store/src/postgres/query/replay_candidate_queries.rs
Normal file
@@ -0,0 +1,474 @@
|
||||
// file: ks-store/src/postgres/query/replay_candidate_queries.rs
|
||||
// version: 3
|
||||
|
||||
//! Read-only PostgreSQL queries for replay candidate discovery.
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ReplayTransactionCandidateRow {
|
||||
signature: std::string::String,
|
||||
slot: i64,
|
||||
raw_processing_state: std::string::String,
|
||||
retention_state: std::string::String,
|
||||
has_core_transaction: bool,
|
||||
transaction_failed: std::option::Option<bool>,
|
||||
ledger_status: std::string::String,
|
||||
processor_version: std::option::Option<std::string::String>,
|
||||
attempt_count: i32,
|
||||
outer_instruction_count: i64,
|
||||
inner_instruction_count: i64,
|
||||
outer_program_count: i64,
|
||||
inner_program_count: i64,
|
||||
updated_at: std::string::String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ReplayProgramSummaryRow {
|
||||
program_id: std::string::String,
|
||||
transaction_count: i64,
|
||||
outer_instruction_count: i64,
|
||||
inner_instruction_count: i64,
|
||||
log_count: i64,
|
||||
min_slot: i64,
|
||||
max_slot: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ReplayEntitySummaryRow {
|
||||
entity_kind: std::string::String,
|
||||
entity_value: std::string::String,
|
||||
transaction_count: i64,
|
||||
occurrence_count: i64,
|
||||
min_slot: i64,
|
||||
max_slot: i64,
|
||||
}
|
||||
|
||||
pub(in crate::postgres) async fn list_replay_transaction_candidates(
|
||||
pool: &sqlx::PgPool,
|
||||
filter: &crate::PostgresReplayTransactionFilter,
|
||||
) -> ks_core::Result<std::vec::Vec<crate::PostgresReplayTransactionCandidate>> {
|
||||
let min_slot_result =
|
||||
crate::postgres::query::replay_candidate_queries::optional_sql_bigint(filter.min_slot);
|
||||
let min_slot = match min_slot_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let max_slot_result =
|
||||
crate::postgres::query::replay_candidate_queries::optional_sql_bigint(filter.max_slot);
|
||||
let max_slot = match max_slot_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let entity_kind = match filter.entity_kind {
|
||||
std::option::Option::Some(value) => std::option::Option::Some(value.as_sql()),
|
||||
std::option::Option::None => std::option::Option::None,
|
||||
};
|
||||
let sql = if filter.newest_first {
|
||||
crate::postgres::query::replay_candidate_queries::transaction_candidate_sql_desc()
|
||||
} else {
|
||||
crate::postgres::query::replay_candidate_queries::transaction_candidate_sql_asc()
|
||||
};
|
||||
let query_result = sqlx::query_as::<
|
||||
sqlx::Postgres,
|
||||
crate::postgres::query::replay_candidate_queries::ReplayTransactionCandidateRow,
|
||||
>(sql)
|
||||
.bind(filter.signature_contains.as_deref())
|
||||
.bind(min_slot)
|
||||
.bind(max_slot)
|
||||
.bind(filter.raw_processing_state.as_deref())
|
||||
.bind(filter.ledger_status.as_deref())
|
||||
.bind(filter.program_id.as_deref())
|
||||
.bind(filter.program_scope.as_sql())
|
||||
.bind(entity_kind)
|
||||
.bind(filter.entity_value.as_deref())
|
||||
.bind(i64::from(filter.limit))
|
||||
.fetch_all(pool)
|
||||
.await;
|
||||
let rows = match query_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::db(format!(
|
||||
"postgres replay transaction candidate query failed: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
let mut output = std::vec::Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
output.push(crate::PostgresReplayTransactionCandidate {
|
||||
signature: row.signature,
|
||||
slot: row.slot,
|
||||
raw_processing_state: row.raw_processing_state,
|
||||
retention_state: row.retention_state,
|
||||
has_core_transaction: row.has_core_transaction,
|
||||
transaction_failed: row.transaction_failed,
|
||||
ledger_status: row.ledger_status,
|
||||
processor_version: row.processor_version,
|
||||
attempt_count: row.attempt_count,
|
||||
outer_instruction_count: row.outer_instruction_count,
|
||||
inner_instruction_count: row.inner_instruction_count,
|
||||
outer_program_count: row.outer_program_count,
|
||||
inner_program_count: row.inner_program_count,
|
||||
updated_at: row.updated_at,
|
||||
});
|
||||
}
|
||||
return std::result::Result::Ok(output);
|
||||
}
|
||||
|
||||
pub(in crate::postgres) async fn list_replay_program_summaries(
|
||||
pool: &sqlx::PgPool,
|
||||
filter: &crate::PostgresReplayProgramFilter,
|
||||
) -> ks_core::Result<std::vec::Vec<crate::PostgresReplayProgramSummary>> {
|
||||
let query_result = sqlx::query_as::<sqlx::Postgres, crate::postgres::query::replay_candidate_queries::ReplayProgramSummaryRow>(
|
||||
r#"WITH occurrences AS (
|
||||
SELECT program_id, signature, slot, 'outer'::TEXT AS scope FROM kb_sol_core_instructions
|
||||
UNION ALL
|
||||
SELECT program_id, signature, slot, 'inner'::TEXT AS scope FROM kb_sol_core_inner_instructions
|
||||
UNION ALL
|
||||
SELECT program_id, signature, slot, 'logs'::TEXT AS scope FROM kb_sol_core_logs WHERE program_id IS NOT NULL
|
||||
)
|
||||
SELECT program_id,
|
||||
COUNT(DISTINCT signature)::BIGINT AS transaction_count,
|
||||
COUNT(*) FILTER (WHERE scope = 'outer')::BIGINT AS outer_instruction_count,
|
||||
COUNT(*) FILTER (WHERE scope = 'inner')::BIGINT AS inner_instruction_count,
|
||||
COUNT(*) FILTER (WHERE scope = 'logs')::BIGINT AS log_count,
|
||||
MIN(slot)::BIGINT AS min_slot,
|
||||
MAX(slot)::BIGINT AS max_slot
|
||||
FROM occurrences
|
||||
WHERE ($1::TEXT IS NULL OR program_id ILIKE '%' || $1 || '%')
|
||||
GROUP BY program_id
|
||||
ORDER BY transaction_count DESC, program_id ASC
|
||||
LIMIT $2"#,
|
||||
)
|
||||
.bind(filter.program_id_contains.as_deref())
|
||||
.bind(i64::from(filter.limit))
|
||||
.fetch_all(pool)
|
||||
.await;
|
||||
let rows = match query_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::db(format!(
|
||||
"postgres replay program summary query failed: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
let mut output = std::vec::Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
output.push(crate::PostgresReplayProgramSummary {
|
||||
program_id: row.program_id,
|
||||
transaction_count: row.transaction_count,
|
||||
outer_instruction_count: row.outer_instruction_count,
|
||||
inner_instruction_count: row.inner_instruction_count,
|
||||
log_count: row.log_count,
|
||||
min_slot: row.min_slot,
|
||||
max_slot: row.max_slot,
|
||||
});
|
||||
}
|
||||
return std::result::Result::Ok(output);
|
||||
}
|
||||
|
||||
pub(in crate::postgres) async fn list_replay_entity_summaries(
|
||||
pool: &sqlx::PgPool,
|
||||
filter: &crate::PostgresReplayEntityFilter,
|
||||
) -> ks_core::Result<std::vec::Vec<crate::PostgresReplayEntitySummary>> {
|
||||
let entity_kind = filter.entity_kind.as_sql();
|
||||
let query_result = sqlx::query_as::<
|
||||
sqlx::Postgres,
|
||||
crate::postgres::query::replay_candidate_queries::ReplayEntitySummaryRow,
|
||||
>(
|
||||
r#"WITH entities AS (
|
||||
SELECT 'mint'::TEXT AS entity_kind, mint AS entity_value, signature, slot
|
||||
FROM kb_sol_core_balance_changes
|
||||
WHERE mint IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT 'owner'::TEXT AS entity_kind, owner AS entity_value, signature, slot
|
||||
FROM kb_sol_core_balance_changes
|
||||
WHERE owner IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT 'account_key'::TEXT AS entity_kind, account_key AS entity_value, signature, slot
|
||||
FROM kb_sol_core_account_keys
|
||||
)
|
||||
SELECT entity_kind,
|
||||
entity_value,
|
||||
COUNT(DISTINCT signature)::BIGINT AS transaction_count,
|
||||
COUNT(*)::BIGINT AS occurrence_count,
|
||||
MIN(slot)::BIGINT AS min_slot,
|
||||
MAX(slot)::BIGINT AS max_slot
|
||||
FROM entities
|
||||
WHERE entity_kind = $1
|
||||
AND ($2::TEXT IS NULL OR entity_value ILIKE '%' || $2 || '%')
|
||||
GROUP BY entity_kind, entity_value
|
||||
ORDER BY transaction_count DESC, entity_value ASC
|
||||
LIMIT $3"#,
|
||||
)
|
||||
.bind(entity_kind)
|
||||
.bind(filter.entity_value_contains.as_deref())
|
||||
.bind(i64::from(filter.limit))
|
||||
.fetch_all(pool)
|
||||
.await;
|
||||
let rows = match query_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::db(format!(
|
||||
"postgres replay entity summary query failed: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
let mut output = std::vec::Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
output.push(crate::PostgresReplayEntitySummary {
|
||||
entity_kind: row.entity_kind,
|
||||
entity_value: row.entity_value,
|
||||
transaction_count: row.transaction_count,
|
||||
occurrence_count: row.occurrence_count,
|
||||
min_slot: row.min_slot,
|
||||
max_slot: row.max_slot,
|
||||
});
|
||||
}
|
||||
return std::result::Result::Ok(output);
|
||||
}
|
||||
|
||||
fn transaction_candidate_sql_desc() -> &'static str {
|
||||
return crate::postgres::query::replay_candidate_queries::transaction_candidate_sql("DESC");
|
||||
}
|
||||
|
||||
fn transaction_candidate_sql_asc() -> &'static str {
|
||||
return crate::postgres::query::replay_candidate_queries::transaction_candidate_sql("ASC");
|
||||
}
|
||||
|
||||
fn transaction_candidate_sql(order: &str) -> &'static str {
|
||||
if order == "DESC" {
|
||||
return r#"SELECT raw.signature,
|
||||
raw.slot,
|
||||
raw.processing_state AS raw_processing_state,
|
||||
raw.retention_state,
|
||||
(core.id IS NOT NULL) AS has_core_transaction,
|
||||
core.failed AS transaction_failed,
|
||||
COALESCE(ledger.status, 'not_started') AS ledger_status,
|
||||
ledger.processor_version,
|
||||
COALESCE(ledger.attempt_count, 0)::INTEGER AS attempt_count,
|
||||
COALESCE(outer_stats.instruction_count, 0)::BIGINT AS outer_instruction_count,
|
||||
COALESCE(inner_stats.instruction_count, 0)::BIGINT AS inner_instruction_count,
|
||||
COALESCE(outer_stats.program_count, 0)::BIGINT AS outer_program_count,
|
||||
COALESCE(inner_stats.program_count, 0)::BIGINT AS inner_program_count,
|
||||
raw.updated_at::TEXT AS updated_at
|
||||
FROM kb_sol_raw_transactions raw
|
||||
LEFT JOIN kb_sol_core_transactions core ON core.signature = raw.signature
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT status, processor_version, attempt_count
|
||||
FROM kb_sol_ops_processing_ledger
|
||||
WHERE stage = 'core_extraction'
|
||||
AND processor_name = 'canonical_to_core'
|
||||
AND input_key = raw.signature
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
LIMIT 1
|
||||
) ledger ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*)::BIGINT AS instruction_count, COUNT(DISTINCT program_id)::BIGINT AS program_count
|
||||
FROM kb_sol_core_instructions
|
||||
WHERE signature = raw.signature
|
||||
) outer_stats ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*)::BIGINT AS instruction_count, COUNT(DISTINCT program_id)::BIGINT AS program_count
|
||||
FROM kb_sol_core_inner_instructions
|
||||
WHERE signature = raw.signature
|
||||
) inner_stats ON TRUE
|
||||
WHERE ($1::TEXT IS NULL OR raw.signature ILIKE '%' || $1 || '%')
|
||||
AND ($2::BIGINT IS NULL OR raw.slot >= $2)
|
||||
AND ($3::BIGINT IS NULL OR raw.slot <= $3)
|
||||
AND ($4::TEXT IS NULL OR raw.processing_state = $4)
|
||||
AND ($5::TEXT IS NULL OR ($5 = 'not_started' AND ledger.status IS NULL) OR ledger.status = $5)
|
||||
AND ($6::TEXT IS NULL OR
|
||||
($7 = 'any' AND (
|
||||
EXISTS (SELECT 1 FROM kb_sol_core_instructions candidate_outer WHERE candidate_outer.signature = raw.signature AND candidate_outer.program_id = $6) OR
|
||||
EXISTS (SELECT 1 FROM kb_sol_core_inner_instructions candidate_inner WHERE candidate_inner.signature = raw.signature AND candidate_inner.program_id = $6) OR
|
||||
EXISTS (SELECT 1 FROM kb_sol_core_logs candidate_log WHERE candidate_log.signature = raw.signature AND candidate_log.program_id = $6)
|
||||
)) OR
|
||||
($7 = 'outer' AND EXISTS (SELECT 1 FROM kb_sol_core_instructions candidate_outer WHERE candidate_outer.signature = raw.signature AND candidate_outer.program_id = $6)) OR
|
||||
($7 = 'inner' AND EXISTS (SELECT 1 FROM kb_sol_core_inner_instructions candidate_inner WHERE candidate_inner.signature = raw.signature AND candidate_inner.program_id = $6)) OR
|
||||
($7 = 'logs' AND EXISTS (SELECT 1 FROM kb_sol_core_logs candidate_log WHERE candidate_log.signature = raw.signature AND candidate_log.program_id = $6)))
|
||||
AND ($8::TEXT IS NULL OR
|
||||
($8 = 'mint' AND EXISTS (
|
||||
SELECT 1 FROM kb_sol_core_balance_changes candidate_balance
|
||||
WHERE candidate_balance.signature = raw.signature
|
||||
AND candidate_balance.mint = $9
|
||||
)) OR
|
||||
($8 = 'owner' AND EXISTS (
|
||||
SELECT 1 FROM kb_sol_core_balance_changes candidate_balance
|
||||
WHERE candidate_balance.signature = raw.signature
|
||||
AND candidate_balance.owner = $9
|
||||
)) OR
|
||||
($8 = 'account_key' AND EXISTS (
|
||||
SELECT 1 FROM kb_sol_core_account_keys candidate_account
|
||||
WHERE candidate_account.signature = raw.signature
|
||||
AND candidate_account.account_key = $9
|
||||
)))
|
||||
ORDER BY raw.slot DESC, raw.signature DESC
|
||||
LIMIT $10"#;
|
||||
}
|
||||
return r#"SELECT raw.signature,
|
||||
raw.slot,
|
||||
raw.processing_state AS raw_processing_state,
|
||||
raw.retention_state,
|
||||
(core.id IS NOT NULL) AS has_core_transaction,
|
||||
core.failed AS transaction_failed,
|
||||
COALESCE(ledger.status, 'not_started') AS ledger_status,
|
||||
ledger.processor_version,
|
||||
COALESCE(ledger.attempt_count, 0)::INTEGER AS attempt_count,
|
||||
COALESCE(outer_stats.instruction_count, 0)::BIGINT AS outer_instruction_count,
|
||||
COALESCE(inner_stats.instruction_count, 0)::BIGINT AS inner_instruction_count,
|
||||
COALESCE(outer_stats.program_count, 0)::BIGINT AS outer_program_count,
|
||||
COALESCE(inner_stats.program_count, 0)::BIGINT AS inner_program_count,
|
||||
raw.updated_at::TEXT AS updated_at
|
||||
FROM kb_sol_raw_transactions raw
|
||||
LEFT JOIN kb_sol_core_transactions core ON core.signature = raw.signature
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT status, processor_version, attempt_count
|
||||
FROM kb_sol_ops_processing_ledger
|
||||
WHERE stage = 'core_extraction'
|
||||
AND processor_name = 'canonical_to_core'
|
||||
AND input_key = raw.signature
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
LIMIT 1
|
||||
) ledger ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*)::BIGINT AS instruction_count, COUNT(DISTINCT program_id)::BIGINT AS program_count
|
||||
FROM kb_sol_core_instructions
|
||||
WHERE signature = raw.signature
|
||||
) outer_stats ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*)::BIGINT AS instruction_count, COUNT(DISTINCT program_id)::BIGINT AS program_count
|
||||
FROM kb_sol_core_inner_instructions
|
||||
WHERE signature = raw.signature
|
||||
) inner_stats ON TRUE
|
||||
WHERE ($1::TEXT IS NULL OR raw.signature ILIKE '%' || $1 || '%')
|
||||
AND ($2::BIGINT IS NULL OR raw.slot >= $2)
|
||||
AND ($3::BIGINT IS NULL OR raw.slot <= $3)
|
||||
AND ($4::TEXT IS NULL OR raw.processing_state = $4)
|
||||
AND ($5::TEXT IS NULL OR ($5 = 'not_started' AND ledger.status IS NULL) OR ledger.status = $5)
|
||||
AND ($6::TEXT IS NULL OR
|
||||
($7 = 'any' AND (
|
||||
EXISTS (SELECT 1 FROM kb_sol_core_instructions candidate_outer WHERE candidate_outer.signature = raw.signature AND candidate_outer.program_id = $6) OR
|
||||
EXISTS (SELECT 1 FROM kb_sol_core_inner_instructions candidate_inner WHERE candidate_inner.signature = raw.signature AND candidate_inner.program_id = $6) OR
|
||||
EXISTS (SELECT 1 FROM kb_sol_core_logs candidate_log WHERE candidate_log.signature = raw.signature AND candidate_log.program_id = $6)
|
||||
)) OR
|
||||
($7 = 'outer' AND EXISTS (SELECT 1 FROM kb_sol_core_instructions candidate_outer WHERE candidate_outer.signature = raw.signature AND candidate_outer.program_id = $6)) OR
|
||||
($7 = 'inner' AND EXISTS (SELECT 1 FROM kb_sol_core_inner_instructions candidate_inner WHERE candidate_inner.signature = raw.signature AND candidate_inner.program_id = $6)) OR
|
||||
($7 = 'logs' AND EXISTS (SELECT 1 FROM kb_sol_core_logs candidate_log WHERE candidate_log.signature = raw.signature AND candidate_log.program_id = $6)))
|
||||
AND ($8::TEXT IS NULL OR
|
||||
($8 = 'mint' AND EXISTS (
|
||||
SELECT 1 FROM kb_sol_core_balance_changes candidate_balance
|
||||
WHERE candidate_balance.signature = raw.signature
|
||||
AND candidate_balance.mint = $9
|
||||
)) OR
|
||||
($8 = 'owner' AND EXISTS (
|
||||
SELECT 1 FROM kb_sol_core_balance_changes candidate_balance
|
||||
WHERE candidate_balance.signature = raw.signature
|
||||
AND candidate_balance.owner = $9
|
||||
)) OR
|
||||
($8 = 'account_key' AND EXISTS (
|
||||
SELECT 1 FROM kb_sol_core_account_keys candidate_account
|
||||
WHERE candidate_account.signature = raw.signature
|
||||
AND candidate_account.account_key = $9
|
||||
)))
|
||||
ORDER BY raw.slot ASC, raw.signature ASC
|
||||
LIMIT $10"#;
|
||||
}
|
||||
|
||||
fn optional_sql_bigint(
|
||||
value: std::option::Option<u64>,
|
||||
) -> ks_core::Result<std::option::Option<i64>> {
|
||||
return match value {
|
||||
std::option::Option::Some(raw_value) => {
|
||||
let conversion_result = i64::try_from(raw_value);
|
||||
match conversion_result {
|
||||
std::result::Result::Ok(converted) => {
|
||||
std::result::Result::Ok(std::option::Option::Some(converted))
|
||||
},
|
||||
std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::db(
|
||||
format!("replay candidate slot does not fit into PostgreSQL BIGINT: {error}"),
|
||||
)),
|
||||
}
|
||||
},
|
||||
std::option::Option::None => std::result::Result::Ok(std::option::Option::None),
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[tokio::test]
|
||||
async fn optional_postgres_replay_candidate_queries_from_env() {
|
||||
let database_url = match std::env::var("KB_POSTGRES_TEST_URL") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_error) => return,
|
||||
};
|
||||
let _postgres_guard = crate::postgres::test_serial::postgres_test_guard().await;
|
||||
let options_result = crate::PostgresStoreOptions::new(database_url, 1, 5000, false);
|
||||
let options = match options_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("unexpected options error: {error}"),
|
||||
};
|
||||
let store_result = crate::PostgresStore::connect(options).await;
|
||||
let store = match store_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("unexpected connect error: {error}"),
|
||||
};
|
||||
let raw_schema_result = store.initialize_raw_store_schema().await;
|
||||
if let std::result::Result::Err(error) = raw_schema_result {
|
||||
panic!("unexpected raw schema error: {error}");
|
||||
}
|
||||
let core_schema_result = store.initialize_core_store_schema().await;
|
||||
if let std::result::Result::Err(error) = core_schema_result {
|
||||
panic!("unexpected core schema error: {error}");
|
||||
}
|
||||
let transaction_filter_result = crate::PostgresReplayTransactionFilter::new(
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
crate::PostgresReplayProgramScope::Any,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
10,
|
||||
true,
|
||||
);
|
||||
let transaction_filter = match transaction_filter_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
panic!("unexpected transaction filter error: {error}")
|
||||
},
|
||||
};
|
||||
let transaction_result = store.replay_transaction_candidates(&transaction_filter).await;
|
||||
if let std::result::Result::Err(error) = transaction_result {
|
||||
panic!("unexpected transaction candidate query error: {error}");
|
||||
}
|
||||
let program_filter_result =
|
||||
crate::PostgresReplayProgramFilter::new(std::option::Option::None, 10);
|
||||
let program_filter = match program_filter_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("unexpected program filter error: {error}"),
|
||||
};
|
||||
let program_result = store.replay_program_summaries(&program_filter).await;
|
||||
if let std::result::Result::Err(error) = program_result {
|
||||
panic!("unexpected program summary query error: {error}");
|
||||
}
|
||||
for entity_kind in [
|
||||
crate::PostgresReplayEntityKind::Mint,
|
||||
crate::PostgresReplayEntityKind::Owner,
|
||||
crate::PostgresReplayEntityKind::AccountKey,
|
||||
] {
|
||||
let entity_filter_result =
|
||||
crate::PostgresReplayEntityFilter::new(entity_kind, std::option::Option::None, 10);
|
||||
let entity_filter = match entity_filter_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
panic!("unexpected entity filter error: {error}")
|
||||
},
|
||||
};
|
||||
let entity_result = store.replay_entity_summaries(&entity_filter).await;
|
||||
if let std::result::Result::Err(error) = entity_result {
|
||||
panic!("unexpected entity summary query error: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
197
ks-store/src/postgres/query/table_diagnostics_queries.rs
Normal file
197
ks-store/src/postgres/query/table_diagnostics_queries.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
// file: ks-store/src/postgres/query/table_diagnostics_queries.rs
|
||||
// version: 3
|
||||
|
||||
//! Read-only PostgreSQL diagnostics for known Solana store tables.
|
||||
|
||||
use sqlx::Row; // rust-rules: trait-import
|
||||
|
||||
pub(in crate::postgres) async fn table_exists(
|
||||
pool: &sqlx::PgPool,
|
||||
table_name: &str,
|
||||
) -> ks_core::Result<bool> {
|
||||
let query_result =
|
||||
sqlx::query_scalar::<sqlx::Postgres, bool>("SELECT to_regclass($1)::text IS NOT NULL")
|
||||
.bind(table_name)
|
||||
.fetch_one(pool)
|
||||
.await;
|
||||
return match query_result {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::db(format!(
|
||||
"postgres table existence diagnostic failed for '{table_name}': {error}"
|
||||
))),
|
||||
};
|
||||
}
|
||||
|
||||
pub(in crate::postgres) async fn load_table_statistics(
|
||||
pool: &sqlx::PgPool,
|
||||
table_name: &str,
|
||||
) -> ks_core::Result<crate::PostgresTableStatistics> {
|
||||
return match table_name {
|
||||
crate::RAW_TRANSACTIONS_TABLE_NAME => {
|
||||
crate::postgres::query::table_diagnostics_queries::load_table_statistics_from_sql(
|
||||
pool,
|
||||
crate::postgres::migrations::table_stats_kb_sol_raw_transactions_sql(),
|
||||
table_name,
|
||||
)
|
||||
.await
|
||||
},
|
||||
crate::TRANSACTION_OBSERVATIONS_TABLE_NAME => {
|
||||
crate::postgres::query::table_diagnostics_queries::load_table_statistics_from_sql(
|
||||
pool,
|
||||
crate::postgres::migrations::table_stats_kb_sol_obs_transaction_observations_sql(),
|
||||
table_name,
|
||||
)
|
||||
.await
|
||||
},
|
||||
crate::CORE_TRANSACTIONS_TABLE_NAME => {
|
||||
crate::postgres::query::table_diagnostics_queries::load_table_statistics_from_sql(
|
||||
pool,
|
||||
crate::postgres::migrations::table_stats_kb_sol_core_transactions_sql(),
|
||||
table_name,
|
||||
)
|
||||
.await
|
||||
},
|
||||
crate::CORE_ACCOUNT_KEYS_TABLE_NAME => {
|
||||
crate::postgres::query::table_diagnostics_queries::load_table_statistics_from_sql(
|
||||
pool,
|
||||
crate::postgres::migrations::table_stats_kb_sol_core_account_keys_sql(),
|
||||
table_name,
|
||||
)
|
||||
.await
|
||||
},
|
||||
crate::CORE_INSTRUCTIONS_TABLE_NAME => {
|
||||
crate::postgres::query::table_diagnostics_queries::load_table_statistics_from_sql(
|
||||
pool,
|
||||
crate::postgres::migrations::table_stats_kb_sol_core_instructions_sql(),
|
||||
table_name,
|
||||
)
|
||||
.await
|
||||
},
|
||||
crate::CORE_INNER_INSTRUCTIONS_TABLE_NAME => {
|
||||
crate::postgres::query::table_diagnostics_queries::load_table_statistics_from_sql(
|
||||
pool,
|
||||
crate::postgres::migrations::table_stats_kb_sol_core_inner_instructions_sql(),
|
||||
table_name,
|
||||
)
|
||||
.await
|
||||
},
|
||||
crate::CORE_LOGS_TABLE_NAME => {
|
||||
crate::postgres::query::table_diagnostics_queries::load_table_statistics_from_sql(
|
||||
pool,
|
||||
crate::postgres::migrations::table_stats_kb_sol_core_logs_sql(),
|
||||
table_name,
|
||||
)
|
||||
.await
|
||||
},
|
||||
crate::CORE_BALANCE_CHANGES_TABLE_NAME => {
|
||||
crate::postgres::query::table_diagnostics_queries::load_table_statistics_from_sql(
|
||||
pool,
|
||||
crate::postgres::migrations::table_stats_kb_sol_core_balance_changes_sql(),
|
||||
table_name,
|
||||
)
|
||||
.await
|
||||
},
|
||||
crate::PROCESSING_LEDGER_TABLE_NAME => {
|
||||
crate::postgres::query::table_diagnostics_queries::load_table_statistics_from_sql(
|
||||
pool,
|
||||
crate::postgres::migrations::table_stats_kb_sol_ops_processing_ledger_sql(),
|
||||
table_name,
|
||||
)
|
||||
.await
|
||||
},
|
||||
crate::DECODE_EVENTS_TABLE_NAME => {
|
||||
crate::postgres::query::table_diagnostics_queries::load_table_statistics_from_sql(
|
||||
pool,
|
||||
crate::postgres::migrations::table_stats_kb_sol_decode_events_sql(),
|
||||
table_name,
|
||||
)
|
||||
.await
|
||||
},
|
||||
crate::DECODE_COVERAGE_DECLARATIONS_TABLE_NAME => {
|
||||
crate::postgres::query::table_diagnostics_queries::load_table_statistics_from_sql(
|
||||
pool,
|
||||
crate::postgres::migrations::table_stats_kb_sol_decode_coverage_declarations_sql(),
|
||||
table_name,
|
||||
)
|
||||
.await
|
||||
},
|
||||
crate::DECODE_COVERAGE_OBSERVATIONS_TABLE_NAME => {
|
||||
crate::postgres::query::table_diagnostics_queries::load_table_statistics_from_sql(
|
||||
pool,
|
||||
crate::postgres::migrations::table_stats_kb_sol_decode_coverage_observations_sql(),
|
||||
table_name,
|
||||
)
|
||||
.await
|
||||
},
|
||||
crate::MATERIALIZED_EVENTS_TABLE_NAME => {
|
||||
crate::postgres::query::table_diagnostics_queries::load_table_statistics_from_sql(
|
||||
pool,
|
||||
crate::postgres::migrations::table_stats_kb_sol_mat_events_sql(),
|
||||
table_name,
|
||||
)
|
||||
.await
|
||||
},
|
||||
_ => std::result::Result::Err(ks_core::Error::db(format!(
|
||||
"postgres table statistics are not supported for '{table_name}'"
|
||||
))),
|
||||
};
|
||||
}
|
||||
|
||||
async fn load_table_statistics_from_sql(
|
||||
pool: &sqlx::PgPool,
|
||||
sql: &'static str,
|
||||
table_name: &str,
|
||||
) -> ks_core::Result<crate::PostgresTableStatistics> {
|
||||
let query_result = sqlx::query(sql).fetch_one(pool).await;
|
||||
let row = match query_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::db(format!(
|
||||
"postgres table statistics failed for '{table_name}': {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
let row_count_result = row.try_get::<i64, _>("row_count");
|
||||
let row_count = match row_count_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::db(format!(
|
||||
"postgres table row_count mapping failed for '{table_name}': {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
let min_slot_result = row.try_get::<std::option::Option<i64>, _>("min_slot");
|
||||
let min_slot = match min_slot_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::db(format!(
|
||||
"postgres table min_slot mapping failed for '{table_name}': {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
let max_slot_result = row.try_get::<std::option::Option<i64>, _>("max_slot");
|
||||
let max_slot = match max_slot_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::db(format!(
|
||||
"postgres table max_slot mapping failed for '{table_name}': {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
let latest_created_at_result =
|
||||
row.try_get::<std::option::Option<std::string::String>, _>("latest_created_at");
|
||||
let latest_created_at = match latest_created_at_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::db(format!(
|
||||
"postgres table latest_created_at mapping failed for '{table_name}': {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
return std::result::Result::Ok(crate::PostgresTableStatistics {
|
||||
row_count,
|
||||
min_slot,
|
||||
max_slot,
|
||||
latest_created_at,
|
||||
});
|
||||
}
|
||||
380
ks-store/src/postgres/replay_candidates.rs
Normal file
380
ks-store/src/postgres/replay_candidates.rs
Normal file
@@ -0,0 +1,380 @@
|
||||
// file: ks-store/src/postgres/replay_candidates.rs
|
||||
// version: 3
|
||||
|
||||
//! Read-only replay candidate filters and PostgreSQL result rows.
|
||||
|
||||
/// Maximum number of rows returned by one replay candidate query.
|
||||
pub const MAX_REPLAY_CANDIDATE_ROWS: u32 = 100_000;
|
||||
|
||||
/// Program occurrence scope used while filtering replay candidates.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PostgresReplayProgramScope {
|
||||
/// Match outer instructions, inner instructions or reliably linked logs.
|
||||
Any,
|
||||
/// Match only top-level instructions.
|
||||
Outer,
|
||||
/// Match only inner instructions.
|
||||
Inner,
|
||||
/// Match only logs with a reliably linked program id.
|
||||
Logs,
|
||||
}
|
||||
|
||||
impl PostgresReplayProgramScope {
|
||||
/// Returns the stable SQL code for this scope.
|
||||
pub fn as_sql(self) -> &'static str {
|
||||
return match self {
|
||||
Self::Any => "any",
|
||||
Self::Outer => "outer",
|
||||
Self::Inner => "inner",
|
||||
Self::Logs => "logs",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Core entity kind used while filtering replay candidates.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PostgresReplayEntityKind {
|
||||
/// SPL or Token-2022 mint address.
|
||||
Mint,
|
||||
/// Token account owner address.
|
||||
Owner,
|
||||
/// Native or token account address.
|
||||
AccountKey,
|
||||
}
|
||||
|
||||
impl PostgresReplayEntityKind {
|
||||
/// Returns the stable SQL code for this entity kind.
|
||||
pub fn as_sql(self) -> &'static str {
|
||||
return match self {
|
||||
Self::Mint => "mint",
|
||||
Self::Owner => "owner",
|
||||
Self::AccountKey => "account_key",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded read-only filter for transaction replay candidates.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct PostgresReplayTransactionFilter {
|
||||
/// Optional partial signature search.
|
||||
pub signature_contains: std::option::Option<std::string::String>,
|
||||
/// Optional inclusive minimum slot.
|
||||
pub min_slot: std::option::Option<u64>,
|
||||
/// Optional inclusive maximum slot.
|
||||
pub max_slot: std::option::Option<u64>,
|
||||
/// Optional raw processing state.
|
||||
pub raw_processing_state: std::option::Option<std::string::String>,
|
||||
/// Optional latest core extraction ledger status, including `not_started`.
|
||||
pub ledger_status: std::option::Option<std::string::String>,
|
||||
/// Optional exact program id.
|
||||
pub program_id: std::option::Option<std::string::String>,
|
||||
/// Program occurrence scope.
|
||||
pub program_scope: crate::PostgresReplayProgramScope,
|
||||
/// Optional core entity kind.
|
||||
pub entity_kind: std::option::Option<crate::PostgresReplayEntityKind>,
|
||||
/// Optional exact entity value.
|
||||
pub entity_value: std::option::Option<std::string::String>,
|
||||
/// Maximum returned rows.
|
||||
pub limit: u32,
|
||||
/// Orders newest slots first when true.
|
||||
pub newest_first: bool,
|
||||
}
|
||||
|
||||
impl PostgresReplayTransactionFilter {
|
||||
/// Creates and validates a bounded transaction candidate filter.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
signature_contains: std::option::Option<std::string::String>,
|
||||
min_slot: std::option::Option<u64>,
|
||||
max_slot: std::option::Option<u64>,
|
||||
raw_processing_state: std::option::Option<std::string::String>,
|
||||
ledger_status: std::option::Option<std::string::String>,
|
||||
program_id: std::option::Option<std::string::String>,
|
||||
program_scope: crate::PostgresReplayProgramScope,
|
||||
entity_kind: std::option::Option<crate::PostgresReplayEntityKind>,
|
||||
entity_value: std::option::Option<std::string::String>,
|
||||
limit: u32,
|
||||
newest_first: bool,
|
||||
) -> ks_core::Result<Self> {
|
||||
let signature_contains_value = trim_optional_text(signature_contains);
|
||||
let raw_processing_state_value = trim_optional_text(raw_processing_state);
|
||||
let ledger_status_value = trim_optional_text(ledger_status);
|
||||
let program_id_value = trim_optional_text(program_id);
|
||||
let entity_value_value = trim_optional_text(entity_value);
|
||||
let slot_result = validate_slot_range(min_slot, max_slot);
|
||||
if let std::result::Result::Err(error) = slot_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let limit_result = validate_limit(limit);
|
||||
if let std::result::Result::Err(error) = limit_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let raw_state_result = validate_optional_code(
|
||||
raw_processing_state_value.as_deref(),
|
||||
&["received", "core_extracted", "decoded", "materialized", "failed"],
|
||||
"raw processing state",
|
||||
);
|
||||
if let std::result::Result::Err(error) = raw_state_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let ledger_status_result = validate_optional_code(
|
||||
ledger_status_value.as_deref(),
|
||||
&["not_started", "running", "succeeded", "failed"],
|
||||
"ledger status",
|
||||
);
|
||||
if let std::result::Result::Err(error) = ledger_status_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if entity_kind.is_some() != entity_value_value.is_some() {
|
||||
return std::result::Result::Err(ks_core::Error::db(
|
||||
"replay entity kind and value must either both be present or both be absent",
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(Self {
|
||||
signature_contains: signature_contains_value,
|
||||
min_slot,
|
||||
max_slot,
|
||||
raw_processing_state: raw_processing_state_value,
|
||||
ledger_status: ledger_status_value,
|
||||
program_id: program_id_value,
|
||||
program_scope,
|
||||
entity_kind,
|
||||
entity_value: entity_value_value,
|
||||
limit,
|
||||
newest_first,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// One raw transaction candidate enriched with core and ledger diagnostics.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct PostgresReplayTransactionCandidate {
|
||||
/// Canonical transaction signature.
|
||||
pub signature: std::string::String,
|
||||
/// Transaction slot.
|
||||
pub slot: i64,
|
||||
/// Current raw processing state.
|
||||
pub raw_processing_state: std::string::String,
|
||||
/// Current raw retention state.
|
||||
pub retention_state: std::string::String,
|
||||
/// Whether a core transaction row exists.
|
||||
pub has_core_transaction: bool,
|
||||
/// Core transaction failure flag when a core row exists.
|
||||
pub transaction_failed: std::option::Option<bool>,
|
||||
/// Latest core extraction ledger status or `not_started`.
|
||||
pub ledger_status: std::string::String,
|
||||
/// Latest core extraction processor version when available.
|
||||
pub processor_version: std::option::Option<std::string::String>,
|
||||
/// Latest core extraction attempt count.
|
||||
pub attempt_count: i32,
|
||||
/// Number of top-level instructions.
|
||||
pub outer_instruction_count: i64,
|
||||
/// Number of inner instructions.
|
||||
pub inner_instruction_count: i64,
|
||||
/// Number of distinct top-level programs.
|
||||
pub outer_program_count: i64,
|
||||
/// Number of distinct inner programs.
|
||||
pub inner_program_count: i64,
|
||||
/// Raw row update timestamp rendered by PostgreSQL.
|
||||
pub updated_at: std::string::String,
|
||||
}
|
||||
|
||||
/// Bounded read-only filter for program summaries.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct PostgresReplayProgramFilter {
|
||||
/// Optional partial program id search.
|
||||
pub program_id_contains: std::option::Option<std::string::String>,
|
||||
/// Maximum returned rows.
|
||||
pub limit: u32,
|
||||
}
|
||||
|
||||
impl PostgresReplayProgramFilter {
|
||||
/// Creates and validates a bounded program summary filter.
|
||||
pub fn new(
|
||||
program_id_contains: std::option::Option<std::string::String>,
|
||||
limit: u32,
|
||||
) -> ks_core::Result<Self> {
|
||||
let limit_result = validate_limit(limit);
|
||||
if let std::result::Result::Err(error) = limit_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(Self {
|
||||
program_id_contains: trim_optional_text(program_id_contains),
|
||||
limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggregated program occurrences across outer, inner and linked logs.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct PostgresReplayProgramSummary {
|
||||
/// Program id.
|
||||
pub program_id: std::string::String,
|
||||
/// Number of distinct transactions containing the program.
|
||||
pub transaction_count: i64,
|
||||
/// Number of top-level instruction occurrences.
|
||||
pub outer_instruction_count: i64,
|
||||
/// Number of inner instruction occurrences.
|
||||
pub inner_instruction_count: i64,
|
||||
/// Number of reliably linked log occurrences.
|
||||
pub log_count: i64,
|
||||
/// Lowest observed slot.
|
||||
pub min_slot: i64,
|
||||
/// Highest observed slot.
|
||||
pub max_slot: i64,
|
||||
}
|
||||
|
||||
/// Bounded read-only filter for core entity summaries.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct PostgresReplayEntityFilter {
|
||||
/// Entity kind to aggregate.
|
||||
pub entity_kind: crate::PostgresReplayEntityKind,
|
||||
/// Optional partial entity value search.
|
||||
pub entity_value_contains: std::option::Option<std::string::String>,
|
||||
/// Maximum returned rows.
|
||||
pub limit: u32,
|
||||
}
|
||||
|
||||
impl PostgresReplayEntityFilter {
|
||||
/// Creates and validates a bounded entity summary filter.
|
||||
pub fn new(
|
||||
entity_kind: crate::PostgresReplayEntityKind,
|
||||
entity_value_contains: std::option::Option<std::string::String>,
|
||||
limit: u32,
|
||||
) -> ks_core::Result<Self> {
|
||||
let limit_result = validate_limit(limit);
|
||||
if let std::result::Result::Err(error) = limit_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(Self {
|
||||
entity_kind,
|
||||
entity_value_contains: trim_optional_text(entity_value_contains),
|
||||
limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggregated mint, owner or account-key occurrences from core tables.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct PostgresReplayEntitySummary {
|
||||
/// Stable entity kind code.
|
||||
pub entity_kind: std::string::String,
|
||||
/// Mint, owner or account-key address.
|
||||
pub entity_value: std::string::String,
|
||||
/// Number of distinct transactions containing the entity.
|
||||
pub transaction_count: i64,
|
||||
/// Total number of core-table occurrences.
|
||||
pub occurrence_count: i64,
|
||||
/// Lowest observed slot.
|
||||
pub min_slot: i64,
|
||||
/// Highest observed slot.
|
||||
pub max_slot: i64,
|
||||
}
|
||||
|
||||
fn trim_optional_text(
|
||||
value: std::option::Option<std::string::String>,
|
||||
) -> std::option::Option<std::string::String> {
|
||||
return match value {
|
||||
std::option::Option::Some(text) => {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return std::option::Option::None;
|
||||
}
|
||||
std::option::Option::Some(trimmed.to_string())
|
||||
},
|
||||
std::option::Option::None => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
fn validate_slot_range(
|
||||
min_slot: std::option::Option<u64>,
|
||||
max_slot: std::option::Option<u64>,
|
||||
) -> ks_core::Result<()> {
|
||||
if let (std::option::Option::Some(minimum), std::option::Option::Some(maximum)) =
|
||||
(min_slot, max_slot)
|
||||
{
|
||||
if minimum > maximum {
|
||||
return std::result::Result::Err(ks_core::Error::db(
|
||||
"replay candidate minimum slot must not exceed maximum slot",
|
||||
));
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn validate_limit(limit: u32) -> ks_core::Result<()> {
|
||||
if limit == 0 || limit > crate::MAX_REPLAY_CANDIDATE_ROWS {
|
||||
return std::result::Result::Err(ks_core::Error::db(format!(
|
||||
"replay candidate limit must be between 1 and {}",
|
||||
crate::MAX_REPLAY_CANDIDATE_ROWS
|
||||
)));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn validate_optional_code(
|
||||
value: std::option::Option<&str>,
|
||||
allowed: &[&str],
|
||||
label: &str,
|
||||
) -> ks_core::Result<()> {
|
||||
let selected = match value {
|
||||
std::option::Option::Some(code) => code,
|
||||
std::option::Option::None => return std::result::Result::Ok(()),
|
||||
};
|
||||
for allowed_code in allowed {
|
||||
if selected == *allowed_code {
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
}
|
||||
return std::result::Result::Err(ks_core::Error::db(format!(
|
||||
"unsupported {label}: {selected}"
|
||||
)));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn transaction_filter_rejects_inverted_slots() {
|
||||
let result = crate::PostgresReplayTransactionFilter::new(
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(20),
|
||||
std::option::Option::Some(10),
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
crate::PostgresReplayProgramScope::Any,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
100,
|
||||
true,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transaction_filter_requires_complete_entity_pair() {
|
||||
let result = crate::PostgresReplayTransactionFilter::new(
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
crate::PostgresReplayProgramScope::Any,
|
||||
std::option::Option::Some(crate::PostgresReplayEntityKind::Mint),
|
||||
std::option::Option::None,
|
||||
100,
|
||||
true,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn program_filter_rejects_limit_above_maximum() {
|
||||
let result = crate::PostgresReplayProgramFilter::new(
|
||||
std::option::Option::None,
|
||||
crate::MAX_REPLAY_CANDIDATE_ROWS + 1,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
10
ks-store/src/postgres/repository.rs
Normal file
10
ks-store/src/postgres/repository.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
// file: ks-store/src/postgres/repository.rs
|
||||
// version: 2
|
||||
|
||||
//! PostgreSQL repository implementations.
|
||||
|
||||
mod core_extraction_repository;
|
||||
mod core_transaction_repository;
|
||||
mod decode_pipeline_repository;
|
||||
mod raw_transaction_repository;
|
||||
mod store_health_repository;
|
||||
@@ -0,0 +1,57 @@
|
||||
// file: ks-store/src/postgres/repository/core_extraction_repository.rs
|
||||
// version: 2
|
||||
|
||||
//! PostgreSQL atomic canonical transaction to core extraction repository.
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::CoreExtractionStore for crate::PostgresStore {
|
||||
#[expect(
|
||||
clippy::implicit_return,
|
||||
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||
)]
|
||||
async fn list_raw_transactions_for_core_extraction(
|
||||
&self,
|
||||
filter: &crate::CoreExtractionSelectionFilter,
|
||||
) -> ks_core::Result<std::vec::Vec<crate::RawTransactionRow>> {
|
||||
return crate::postgres::query::list_raw_transactions_for_core_extraction(
|
||||
self.pool(),
|
||||
filter,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::implicit_return,
|
||||
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||
)]
|
||||
async fn is_core_extraction_current(
|
||||
&self,
|
||||
identity: &crate::ProcessingLedgerIdentity,
|
||||
) -> ks_core::Result<bool> {
|
||||
return crate::postgres::query::is_core_extraction_current(self.pool(), identity).await;
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::implicit_return,
|
||||
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||
)]
|
||||
async fn persist_core_extraction(
|
||||
&self,
|
||||
bundle: &crate::CoreExtractionBundle,
|
||||
force_replay: bool,
|
||||
) -> ks_core::Result<crate::InsertOutcome> {
|
||||
return crate::postgres::query::persist_core_extraction(self.pool(), bundle, force_replay)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::implicit_return,
|
||||
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||
)]
|
||||
async fn mark_core_extraction_failed(
|
||||
&self,
|
||||
failure: &crate::CoreExtractionFailure,
|
||||
) -> ks_core::Result<crate::InsertOutcome> {
|
||||
return crate::postgres::query::mark_core_extraction_failed(self.pool(), failure).await;
|
||||
}
|
||||
}
|
||||
118
ks-store/src/postgres/repository/core_transaction_repository.rs
Normal file
118
ks-store/src/postgres/repository/core_transaction_repository.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
// file: ks-store/src/postgres/repository/core_transaction_repository.rs
|
||||
// version: 2
|
||||
|
||||
//! PostgreSQL core Solana repository implementation.
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::CoreTransactionStore for crate::PostgresStore {
|
||||
#[expect(
|
||||
clippy::implicit_return,
|
||||
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||
)]
|
||||
async fn insert_core_transaction(
|
||||
&self,
|
||||
input: &crate::CoreTransactionInsert,
|
||||
) -> ks_core::Result<crate::InsertOutcome> {
|
||||
return crate::postgres::query::insert_core_transaction(self.pool(), input).await;
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::implicit_return,
|
||||
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||
)]
|
||||
async fn insert_core_account_keys(
|
||||
&self,
|
||||
inputs: &[crate::CoreAccountKeyInsert],
|
||||
) -> ks_core::Result<crate::InsertOutcome> {
|
||||
return crate::postgres::query::insert_core_account_keys(self.pool(), inputs).await;
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::implicit_return,
|
||||
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||
)]
|
||||
async fn insert_core_instructions(
|
||||
&self,
|
||||
inputs: &[crate::CoreInstructionInsert],
|
||||
) -> ks_core::Result<crate::InsertOutcome> {
|
||||
return crate::postgres::query::insert_core_instructions(self.pool(), inputs).await;
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::implicit_return,
|
||||
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||
)]
|
||||
async fn insert_core_inner_instructions(
|
||||
&self,
|
||||
inputs: &[crate::CoreInnerInstructionInsert],
|
||||
) -> ks_core::Result<crate::InsertOutcome> {
|
||||
return crate::postgres::query::insert_core_inner_instructions(self.pool(), inputs).await;
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::implicit_return,
|
||||
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||
)]
|
||||
async fn insert_core_logs(
|
||||
&self,
|
||||
inputs: &[crate::CoreLogInsert],
|
||||
) -> ks_core::Result<crate::InsertOutcome> {
|
||||
return crate::postgres::query::insert_core_logs(self.pool(), inputs).await;
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::implicit_return,
|
||||
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||
)]
|
||||
async fn insert_core_balance_changes(
|
||||
&self,
|
||||
inputs: &[crate::CoreBalanceChangeInsert],
|
||||
) -> ks_core::Result<crate::InsertOutcome> {
|
||||
return crate::postgres::query::insert_core_balance_changes(self.pool(), inputs).await;
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::implicit_return,
|
||||
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||
)]
|
||||
async fn list_core_instructions_for_replay(
|
||||
&self,
|
||||
filter: &crate::CoreInstructionReplayFilter,
|
||||
page_request: &crate::PageRequest,
|
||||
) -> ks_core::Result<std::vec::Vec<crate::CoreInstructionRow>> {
|
||||
return crate::postgres::query::list_core_instructions_for_replay(
|
||||
self.pool(),
|
||||
filter,
|
||||
page_request,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::implicit_return,
|
||||
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||
)]
|
||||
async fn list_core_instruction_replay_inputs(
|
||||
&self,
|
||||
filter: &crate::CoreInstructionReplayFilter,
|
||||
page_request: &crate::PageRequest,
|
||||
) -> ks_core::Result<std::vec::Vec<crate::MdCoreInstructionReplayInput>> {
|
||||
return crate::postgres::query::list_core_instruction_replay_inputs(
|
||||
self.pool(),
|
||||
filter,
|
||||
page_request,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::implicit_return,
|
||||
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||
)]
|
||||
async fn mark_core_instruction_lifecycle(
|
||||
&self,
|
||||
mark: &crate::CoreInstructionLifecycleMark,
|
||||
) -> ks_core::Result<crate::InsertOutcome> {
|
||||
return crate::postgres::query::update_core_instruction_lifecycle(self.pool(), mark).await;
|
||||
}
|
||||
}
|
||||
115
ks-store/src/postgres/repository/decode_pipeline_repository.rs
Normal file
115
ks-store/src/postgres/repository/decode_pipeline_repository.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
// file: ks-store/src/postgres/repository/decode_pipeline_repository.rs
|
||||
// version: 2
|
||||
|
||||
//! PostgreSQL contextual decode and materialization repository.
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::DecodePipelineStore for crate::PostgresStore {
|
||||
#[expect(
|
||||
clippy::implicit_return,
|
||||
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||
)]
|
||||
async fn list_decode_inputs(
|
||||
&self,
|
||||
filter: &crate::DecodeSelectionFilter,
|
||||
) -> ks_core::Result<std::vec::Vec<crate::MdCoreInstructionReplayInput>> {
|
||||
return crate::postgres::query::list_decode_inputs(self.pool(), filter).await;
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::implicit_return,
|
||||
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||
)]
|
||||
async fn is_decode_current(
|
||||
&self,
|
||||
identity: &crate::ProcessingLedgerIdentity,
|
||||
) -> ks_core::Result<bool> {
|
||||
return crate::postgres::query::is_decode_current(self.pool(), identity).await;
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::implicit_return,
|
||||
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||
)]
|
||||
async fn persist_decode_coverage_declarations(
|
||||
&self,
|
||||
declarations: &[crate::DecodeCoverageDeclarationInsert],
|
||||
) -> ks_core::Result<crate::InsertOutcome> {
|
||||
return crate::postgres::query::persist_decode_coverage_declarations(
|
||||
self.pool(),
|
||||
declarations,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::implicit_return,
|
||||
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||
)]
|
||||
async fn persist_decode_result(
|
||||
&self,
|
||||
bundle: &crate::DecodePersistenceBundle,
|
||||
force_replay: bool,
|
||||
) -> ks_core::Result<crate::InsertOutcome> {
|
||||
return crate::postgres::query::persist_decode_result(self.pool(), bundle, force_replay)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::implicit_return,
|
||||
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||
)]
|
||||
async fn mark_decode_failed(
|
||||
&self,
|
||||
failure: &crate::DecodeFailure,
|
||||
) -> ks_core::Result<crate::InsertOutcome> {
|
||||
return crate::postgres::query::mark_decode_failed(self.pool(), failure).await;
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::implicit_return,
|
||||
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||
)]
|
||||
async fn persist_materialization_result(
|
||||
&self,
|
||||
bundle: &crate::MaterializationPersistenceBundle,
|
||||
force_replay: bool,
|
||||
) -> ks_core::Result<crate::InsertOutcome> {
|
||||
return crate::postgres::query::persist_materialization_result(
|
||||
self.pool(),
|
||||
bundle,
|
||||
force_replay,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::implicit_return,
|
||||
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||
)]
|
||||
async fn list_decode_coverage_summary(
|
||||
&self,
|
||||
processor_name: std::option::Option<&str>,
|
||||
processor_version: std::option::Option<&str>,
|
||||
limit: u32,
|
||||
) -> ks_core::Result<std::vec::Vec<crate::DecodeCoverageSummaryRow>> {
|
||||
return crate::postgres::query::list_decode_coverage_summary(
|
||||
self.pool(),
|
||||
processor_name,
|
||||
processor_version,
|
||||
limit,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::implicit_return,
|
||||
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||
)]
|
||||
async fn list_materialized_events(
|
||||
&self,
|
||||
filter: &crate::MaterializedEventFilter,
|
||||
) -> ks_core::Result<std::vec::Vec<crate::MaterializedEventQueryRow>> {
|
||||
return crate::postgres::query::list_materialized_events(self.pool(), filter).await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// file: ks-store/src/postgres/repository/raw_transaction_repository.rs
|
||||
// version: 2
|
||||
|
||||
//! PostgreSQL canonical transaction and acquisition observation repository implementation.
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::RawTransactionStore for crate::PostgresStore {
|
||||
#[expect(
|
||||
clippy::implicit_return,
|
||||
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||
)]
|
||||
async fn has_raw_transaction_signature(
|
||||
&self,
|
||||
signature: &ks_lib::MdSignature,
|
||||
) -> ks_core::Result<bool> {
|
||||
return crate::postgres::query::has_raw_transaction_signature(
|
||||
self.pool(),
|
||||
signature.0.as_str(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::implicit_return,
|
||||
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||
)]
|
||||
async fn has_transaction_observation_key(
|
||||
&self,
|
||||
observation_key: &str,
|
||||
) -> ks_core::Result<bool> {
|
||||
return crate::postgres::query::has_transaction_observation_key(
|
||||
self.pool(),
|
||||
observation_key,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::implicit_return,
|
||||
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||
)]
|
||||
async fn insert_raw_transaction(
|
||||
&self,
|
||||
input: &crate::RawTransactionInsert,
|
||||
) -> ks_core::Result<crate::InsertOutcome> {
|
||||
return crate::postgres::query::insert_raw_transaction(self.pool(), input).await;
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::implicit_return,
|
||||
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||
)]
|
||||
async fn insert_transaction_observation(
|
||||
&self,
|
||||
input: &crate::TransactionObservationInsert,
|
||||
) -> ks_core::Result<crate::InsertOutcome> {
|
||||
return crate::postgres::query::insert_transaction_observation(self.pool(), input).await;
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::implicit_return,
|
||||
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||
)]
|
||||
async fn mark_raw_payload_lifecycle(
|
||||
&self,
|
||||
mark: &crate::RawPayloadLifecycleMark,
|
||||
) -> ks_core::Result<crate::InsertOutcome> {
|
||||
return crate::postgres::query::update_raw_payload_lifecycle(self.pool(), mark).await;
|
||||
}
|
||||
}
|
||||
31
ks-store/src/postgres/repository/store_health_repository.rs
Normal file
31
ks-store/src/postgres/repository/store_health_repository.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
// file: ks-store/src/postgres/repository/store_health_repository.rs
|
||||
// version: 2
|
||||
|
||||
//! PostgreSQL store health repository implementation.
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::StoreHealthStore for crate::PostgresStore {
|
||||
#[expect(
|
||||
clippy::implicit_return,
|
||||
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||
)]
|
||||
async fn backend_descriptor(&self) -> ks_core::Result<crate::StoreBackendDescriptor> {
|
||||
return crate::PostgresStore::backend_descriptor(self).await;
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::implicit_return,
|
||||
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||
)]
|
||||
async fn health_snapshot(&self) -> ks_core::Result<crate::StoreHealthSnapshot> {
|
||||
return crate::PostgresStore::health_snapshot(self).await;
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::implicit_return,
|
||||
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||
)]
|
||||
async fn migration_snapshot(&self) -> ks_core::Result<crate::StoreMigrationSnapshot> {
|
||||
return crate::PostgresStore::migration_snapshot(self).await;
|
||||
}
|
||||
}
|
||||
512
ks-store/src/postgres/store.rs
Normal file
512
ks-store/src/postgres/store.rs
Normal file
@@ -0,0 +1,512 @@
|
||||
// file: ks-store/src/postgres/store.rs
|
||||
// version: 3
|
||||
|
||||
//! Store implementation scaffold for the `ks-store` crate.
|
||||
|
||||
/// PostgreSQL store connection options.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct PostgresStoreOptions {
|
||||
/// Database URL or DSN.
|
||||
pub database_url: std::string::String,
|
||||
/// Maximum connection count.
|
||||
pub max_connections: u32,
|
||||
/// Connection timeout in milliseconds.
|
||||
pub connect_timeout_ms: u64,
|
||||
/// Enables idempotent raw schema initialization at startup.
|
||||
pub auto_initialize_schema: bool,
|
||||
}
|
||||
|
||||
impl PostgresStoreOptions {
|
||||
/// Creates validated PostgreSQL store options.
|
||||
pub fn new(
|
||||
database_url: impl std::convert::Into<std::string::String>,
|
||||
max_connections: u32,
|
||||
connect_timeout_ms: u64,
|
||||
auto_initialize_schema: bool,
|
||||
) -> ks_core::Result<Self> {
|
||||
let database_url_value = database_url.into();
|
||||
if database_url_value.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::db(
|
||||
"postgres database url must not be empty",
|
||||
));
|
||||
}
|
||||
if max_connections == 0 {
|
||||
return std::result::Result::Err(ks_core::Error::db(
|
||||
"postgres max_connections must be greater than zero",
|
||||
));
|
||||
}
|
||||
if connect_timeout_ms == 0 {
|
||||
return std::result::Result::Err(ks_core::Error::db(
|
||||
"postgres connect_timeout_ms must be greater than zero",
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(Self {
|
||||
database_url: database_url_value,
|
||||
max_connections,
|
||||
connect_timeout_ms,
|
||||
auto_initialize_schema,
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns a DSN masked for diagnostics.
|
||||
pub fn masked_dsn(&self) -> std::string::String {
|
||||
return crate::mask_postgres_dsn(self.database_url.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
/// PostgreSQL diagnostic snapshot.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct PostgresBackendDiagnostics {
|
||||
/// Backend descriptor safe for UI display.
|
||||
pub descriptor: crate::StoreBackendDescriptor,
|
||||
/// Backend health snapshot.
|
||||
pub health: crate::StoreHealthSnapshot,
|
||||
/// Migration status snapshot.
|
||||
pub migrations: crate::StoreMigrationSnapshot,
|
||||
/// Full PostgreSQL server version string when available.
|
||||
pub server_version: std::option::Option<std::string::String>,
|
||||
}
|
||||
|
||||
/// Read-only statistics for one PostgreSQL table.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct PostgresTableStatistics {
|
||||
/// Number of rows currently stored in the table.
|
||||
pub row_count: i64,
|
||||
/// Lowest observed Solana slot when the table contains a slot column and rows.
|
||||
pub min_slot: std::option::Option<i64>,
|
||||
/// Highest observed Solana slot when the table contains a slot column and rows.
|
||||
pub max_slot: std::option::Option<i64>,
|
||||
/// Latest insertion timestamp rendered by PostgreSQL for UI diagnostics.
|
||||
pub latest_created_at: std::option::Option<std::string::String>,
|
||||
}
|
||||
|
||||
/// Read-only diagnostics for one expected PostgreSQL table.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct PostgresTableDiagnostics {
|
||||
/// Expected table name.
|
||||
pub table_name: std::string::String,
|
||||
/// Logical Solana domain encoded in the table name.
|
||||
pub domain: std::string::String,
|
||||
/// Human-readable role of the table.
|
||||
pub role: std::string::String,
|
||||
/// Whether the table exists in the current PostgreSQL search path.
|
||||
pub exists: bool,
|
||||
/// Table statistics when the table exists.
|
||||
pub statistics: std::option::Option<crate::PostgresTableStatistics>,
|
||||
}
|
||||
|
||||
/// PostgreSQL store handle.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PostgresStore {
|
||||
options: crate::PostgresStoreOptions,
|
||||
pool: sqlx::PgPool,
|
||||
}
|
||||
|
||||
impl PostgresStore {
|
||||
/// Connects to PostgreSQL from typed store options.
|
||||
pub async fn connect(options: crate::PostgresStoreOptions) -> ks_core::Result<Self> {
|
||||
let pool_options = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(options.max_connections)
|
||||
.acquire_timeout(std::time::Duration::from_millis(options.connect_timeout_ms));
|
||||
let connect_result = pool_options.connect(options.database_url.as_str()).await;
|
||||
return match connect_result {
|
||||
std::result::Result::Ok(pool) => {
|
||||
let store = Self { options, pool };
|
||||
if store.options.auto_initialize_schema {
|
||||
let schema_result = store.initialize_store_schema().await;
|
||||
if let std::result::Result::Err(error) = schema_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
}
|
||||
std::result::Result::Ok(store)
|
||||
},
|
||||
std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::db(
|
||||
format!("postgres connection failed: {error}"),
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
/// Creates a store handle from an existing PostgreSQL pool.
|
||||
pub fn from_pool(options: crate::PostgresStoreOptions, pool: sqlx::PgPool) -> Self {
|
||||
return Self { options, pool };
|
||||
}
|
||||
|
||||
/// Returns the underlying PostgreSQL pool.
|
||||
pub fn pool(&self) -> &sqlx::PgPool {
|
||||
return &self.pool;
|
||||
}
|
||||
|
||||
/// Returns the connection options used to create this store.
|
||||
pub fn options(&self) -> &crate::PostgresStoreOptions {
|
||||
return &self.options;
|
||||
}
|
||||
|
||||
/// Applies each idempotent store schema once per invocation in dependency order.
|
||||
pub async fn initialize_store_schema(&self) -> ks_core::Result<()> {
|
||||
let raw_result = crate::postgres::query::apply_raw_store_schema(&self.pool).await;
|
||||
if let std::result::Result::Err(error) = raw_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let core_result = crate::postgres::query::apply_core_store_schema(&self.pool).await;
|
||||
if let std::result::Result::Err(error) = core_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return crate::postgres::query::apply_decode_store_schema(&self.pool).await;
|
||||
}
|
||||
|
||||
/// Applies the idempotent minimal raw Solana store schema.
|
||||
pub async fn initialize_raw_store_schema(&self) -> ks_core::Result<()> {
|
||||
return crate::postgres::query::apply_raw_store_schema(&self.pool).await;
|
||||
}
|
||||
|
||||
/// Applies the idempotent minimal core Solana store schema.
|
||||
pub async fn initialize_core_store_schema(&self) -> ks_core::Result<()> {
|
||||
let raw_result = self.initialize_raw_store_schema().await;
|
||||
if let std::result::Result::Err(error) = raw_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return crate::postgres::query::apply_core_store_schema(&self.pool).await;
|
||||
}
|
||||
|
||||
/// Applies the idempotent common decode and materialization store schema.
|
||||
pub async fn initialize_decode_store_schema(&self) -> ks_core::Result<()> {
|
||||
let core_result = self.initialize_core_store_schema().await;
|
||||
if let std::result::Result::Err(error) = core_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return crate::postgres::query::apply_decode_store_schema(&self.pool).await;
|
||||
}
|
||||
|
||||
/// Reads a UI-safe backend descriptor.
|
||||
pub async fn backend_descriptor(&self) -> ks_core::Result<crate::StoreBackendDescriptor> {
|
||||
let schema_result = crate::postgres::query::load_current_schema(&self.pool).await;
|
||||
return match schema_result {
|
||||
std::result::Result::Ok(schema) => crate::StoreBackendDescriptor::new(
|
||||
crate::StoreBackendKind::Postgres,
|
||||
"postgres",
|
||||
std::option::Option::Some(self.options.masked_dsn()),
|
||||
std::option::Option::Some(schema),
|
||||
),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
|
||||
/// Reads a PostgreSQL health snapshot.
|
||||
pub async fn health_snapshot(&self) -> ks_core::Result<crate::StoreHealthSnapshot> {
|
||||
let health_result = crate::postgres::query::run_health_check(&self.pool).await;
|
||||
return match health_result {
|
||||
std::result::Result::Ok(()) => crate::StoreHealthSnapshot::new(
|
||||
"postgres",
|
||||
crate::StoreHealthStatus::Healthy,
|
||||
std::option::Option::Some(std::string::String::from("SELECT 1 succeeded")),
|
||||
),
|
||||
std::result::Result::Err(error) => crate::StoreHealthSnapshot::new(
|
||||
"postgres",
|
||||
crate::StoreHealthStatus::Unhealthy,
|
||||
std::option::Option::Some(error.to_string()),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/// Reads a non-destructive migration snapshot.
|
||||
pub async fn migration_snapshot(&self) -> ks_core::Result<crate::StoreMigrationSnapshot> {
|
||||
let migration_table_result =
|
||||
crate::postgres::query::load_migration_table_name(&self.pool).await;
|
||||
return match migration_table_result {
|
||||
std::result::Result::Ok(std::option::Option::None) => {
|
||||
std::result::Result::Ok(crate::StoreMigrationSnapshot::new(
|
||||
crate::StoreMigrationStatus::NotInitialized,
|
||||
std::option::Option::None,
|
||||
std::vec::Vec::new(),
|
||||
std::option::Option::Some(std::string::String::from(
|
||||
"no sqlx migration table detected; 0.3.1 canonical acquisition/core schemas use idempotent crate-managed DDL",
|
||||
)),
|
||||
))
|
||||
},
|
||||
std::result::Result::Ok(std::option::Option::Some(_table_name)) => {
|
||||
self.migration_snapshot_from_existing_table().await
|
||||
},
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
|
||||
/// Reads a complete PostgreSQL diagnostic snapshot.
|
||||
pub async fn backend_diagnostics(&self) -> ks_core::Result<crate::PostgresBackendDiagnostics> {
|
||||
let descriptor_result = self.backend_descriptor().await;
|
||||
let descriptor = match descriptor_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let health_result = self.health_snapshot().await;
|
||||
let health = match health_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let migrations_result = self.migration_snapshot().await;
|
||||
let migrations = match migrations_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let server_version = match crate::postgres::query::load_server_version(&self.pool).await {
|
||||
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||
std::result::Result::Err(_error) => std::option::Option::None,
|
||||
};
|
||||
return std::result::Result::Ok(crate::PostgresBackendDiagnostics {
|
||||
descriptor,
|
||||
health,
|
||||
migrations,
|
||||
server_version,
|
||||
});
|
||||
}
|
||||
|
||||
/// Lists bounded raw transaction candidates enriched with core and ledger diagnostics.
|
||||
pub async fn replay_transaction_candidates(
|
||||
&self,
|
||||
filter: &crate::PostgresReplayTransactionFilter,
|
||||
) -> ks_core::Result<std::vec::Vec<crate::PostgresReplayTransactionCandidate>> {
|
||||
return crate::postgres::query::list_replay_transaction_candidates(&self.pool, filter)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Lists bounded program summaries across outer, inner and reliably linked logs.
|
||||
pub async fn replay_program_summaries(
|
||||
&self,
|
||||
filter: &crate::PostgresReplayProgramFilter,
|
||||
) -> ks_core::Result<std::vec::Vec<crate::PostgresReplayProgramSummary>> {
|
||||
return crate::postgres::query::list_replay_program_summaries(&self.pool, filter).await;
|
||||
}
|
||||
|
||||
/// Lists bounded mint, owner or account-key summaries from core tables.
|
||||
pub async fn replay_entity_summaries(
|
||||
&self,
|
||||
filter: &crate::PostgresReplayEntityFilter,
|
||||
) -> ks_core::Result<std::vec::Vec<crate::PostgresReplayEntitySummary>> {
|
||||
return crate::postgres::query::list_replay_entity_summaries(&self.pool, filter).await;
|
||||
}
|
||||
|
||||
/// Reads diagnostics for raw Solana store tables without changing the schema.
|
||||
pub async fn raw_table_diagnostics(
|
||||
&self,
|
||||
) -> ks_core::Result<std::vec::Vec<crate::PostgresTableDiagnostics>> {
|
||||
let specs = crate::raw_store_table_diagnostic_specs();
|
||||
return self.table_diagnostics(&specs).await;
|
||||
}
|
||||
|
||||
/// Reads diagnostics for core Solana store tables without changing the schema.
|
||||
pub async fn core_table_diagnostics(
|
||||
&self,
|
||||
) -> ks_core::Result<std::vec::Vec<crate::PostgresTableDiagnostics>> {
|
||||
let specs = crate::core_store_table_diagnostic_specs();
|
||||
return self.table_diagnostics(&specs).await;
|
||||
}
|
||||
|
||||
/// Reads diagnostics for decode and materialization store tables without changing the schema.
|
||||
pub async fn decode_table_diagnostics(
|
||||
&self,
|
||||
) -> ks_core::Result<std::vec::Vec<crate::PostgresTableDiagnostics>> {
|
||||
let specs = crate::decode_store_table_diagnostic_specs();
|
||||
return self.table_diagnostics(&specs).await;
|
||||
}
|
||||
|
||||
/// Reads diagnostics for every known raw/core/decode Solana store table.
|
||||
pub async fn known_table_diagnostics(
|
||||
&self,
|
||||
) -> ks_core::Result<std::vec::Vec<crate::PostgresTableDiagnostics>> {
|
||||
let mut diagnostics = std::vec::Vec::new();
|
||||
let raw_result = self.raw_table_diagnostics().await;
|
||||
let raw_tables = match raw_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
for table in raw_tables {
|
||||
diagnostics.push(table);
|
||||
}
|
||||
let core_result = self.core_table_diagnostics().await;
|
||||
let core_tables = match core_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
for table in core_tables {
|
||||
diagnostics.push(table);
|
||||
}
|
||||
let decode_result = self.decode_table_diagnostics().await;
|
||||
let decode_tables = match decode_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
for table in decode_tables {
|
||||
diagnostics.push(table);
|
||||
}
|
||||
return std::result::Result::Ok(diagnostics);
|
||||
}
|
||||
|
||||
async fn table_diagnostics(
|
||||
&self,
|
||||
specs: &[crate::PostgresTableDiagnosticSpec],
|
||||
) -> ks_core::Result<std::vec::Vec<crate::PostgresTableDiagnostics>> {
|
||||
let mut diagnostics = std::vec::Vec::new();
|
||||
for spec in specs {
|
||||
let exists_result =
|
||||
crate::postgres::query::table_exists(&self.pool, spec.table_name).await;
|
||||
let exists = match exists_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let statistics = if exists {
|
||||
let statistics_result =
|
||||
crate::postgres::query::load_table_statistics(&self.pool, spec.table_name)
|
||||
.await;
|
||||
match statistics_result {
|
||||
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
} else {
|
||||
std::option::Option::None
|
||||
};
|
||||
diagnostics.push(crate::PostgresTableDiagnostics {
|
||||
table_name: spec.table_name.to_string(),
|
||||
domain: spec.domain.to_string(),
|
||||
role: spec.role.to_string(),
|
||||
exists,
|
||||
statistics,
|
||||
});
|
||||
}
|
||||
return std::result::Result::Ok(diagnostics);
|
||||
}
|
||||
|
||||
async fn migration_snapshot_from_existing_table(
|
||||
&self,
|
||||
) -> ks_core::Result<crate::StoreMigrationSnapshot> {
|
||||
let version_result =
|
||||
crate::postgres::query::load_latest_migration_version(&self.pool).await;
|
||||
return match version_result {
|
||||
std::result::Result::Ok(current_version) => {
|
||||
std::result::Result::Ok(crate::StoreMigrationSnapshot::new(
|
||||
crate::StoreMigrationStatus::Current,
|
||||
current_version,
|
||||
std::vec::Vec::new(),
|
||||
std::option::Option::Some(std::string::String::from(
|
||||
"sqlx migration table detected; canonical acquisition/core schema remains idempotent and crate-managed in 0.3.1",
|
||||
)),
|
||||
))
|
||||
},
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a DSN masked for logs and UI diagnostics.
|
||||
pub fn mask_postgres_dsn(dsn: &str) -> std::string::String {
|
||||
let trimmed_dsn = dsn.trim();
|
||||
if trimmed_dsn.is_empty() {
|
||||
return std::string::String::from("");
|
||||
}
|
||||
let queryless = crate::postgres::store::strip_query(trimmed_dsn);
|
||||
return match queryless.split_once("://") {
|
||||
std::option::Option::Some((scheme, remainder)) => {
|
||||
crate::postgres::store::mask_scheme_remainder(
|
||||
scheme,
|
||||
remainder,
|
||||
trimmed_dsn.contains('?'),
|
||||
)
|
||||
},
|
||||
std::option::Option::None => crate::postgres::store::mask_plain_dsn(queryless.as_str()),
|
||||
};
|
||||
}
|
||||
|
||||
fn strip_query(dsn: &str) -> std::string::String {
|
||||
return match dsn.split_once('?') {
|
||||
std::option::Option::Some((prefix, _query)) => std::string::String::from(prefix),
|
||||
std::option::Option::None => std::string::String::from(dsn),
|
||||
};
|
||||
}
|
||||
|
||||
fn mask_scheme_remainder(scheme: &str, remainder: &str, had_query: bool) -> std::string::String {
|
||||
let suffix = crate::postgres::store::query_suffix(had_query);
|
||||
return match remainder.rsplit_once('@') {
|
||||
std::option::Option::Some((_userinfo, host_path)) => {
|
||||
format!("{scheme}://***:***@{host_path}{suffix}")
|
||||
},
|
||||
std::option::Option::None => format!("{scheme}://{remainder}{suffix}"),
|
||||
};
|
||||
}
|
||||
|
||||
fn mask_plain_dsn(dsn: &str) -> std::string::String {
|
||||
if dsn.contains("password=") {
|
||||
return std::string::String::from("<postgres-dsn-redacted>");
|
||||
}
|
||||
return std::string::String::from(dsn);
|
||||
}
|
||||
|
||||
fn query_suffix(had_query: bool) -> std::string::String {
|
||||
if had_query {
|
||||
return std::string::String::from("?<redacted>");
|
||||
}
|
||||
return std::string::String::from("");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn options_reject_empty_database_url() {
|
||||
let result = crate::PostgresStoreOptions::new(" ", 1, 1000, false);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_reject_zero_max_connections() {
|
||||
let result = crate::PostgresStoreOptions::new("postgres://localhost/db", 0, 1000, false);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_reject_zero_connect_timeout() {
|
||||
let result = crate::PostgresStoreOptions::new("postgres://localhost/db", 1, 0, false);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn optional_postgres_healthcheck_from_env() {
|
||||
let database_url = match std::env::var("KB_POSTGRES_TEST_URL") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_error) => return,
|
||||
};
|
||||
let _postgres_guard = crate::postgres::test_serial::postgres_test_guard().await;
|
||||
let options_result = crate::PostgresStoreOptions::new(database_url, 1, 5000, false);
|
||||
let options = match options_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("unexpected options error: {error}"),
|
||||
};
|
||||
let store_result = crate::PostgresStore::connect(options).await;
|
||||
let store = match store_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("unexpected connect error: {error}"),
|
||||
};
|
||||
let health_result = store.health_snapshot().await;
|
||||
let health = match health_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("unexpected health error: {error}"),
|
||||
};
|
||||
assert_eq!(health.status, crate::StoreHealthStatus::Healthy);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_postgres_dsn_masks_userinfo() {
|
||||
let masked = crate::mask_postgres_dsn("postgres://user:secret@localhost:5432/db");
|
||||
assert_eq!(masked, "postgres://***:***@localhost:5432/db");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_postgres_dsn_masks_query_string() {
|
||||
let masked =
|
||||
crate::mask_postgres_dsn("postgres://localhost/db?sslmode=require&password=secret");
|
||||
assert_eq!(masked, "postgres://localhost/db?<redacted>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_postgres_dsn_masks_plain_password_dsn() {
|
||||
let masked = crate::mask_postgres_dsn("host=localhost password=secret dbname=khadhroony");
|
||||
assert_eq!(masked, "<postgres-dsn-redacted>");
|
||||
}
|
||||
}
|
||||
15
ks-store/src/postgres/test_serial.rs
Normal file
15
ks-store/src/postgres/test_serial.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
// file: ks-store/src/postgres/test_serial.rs
|
||||
// version: 2
|
||||
|
||||
//! Test-only serialization helpers for optional real PostgreSQL tests.
|
||||
|
||||
static POSTGRES_TEST_MUTEX: std::sync::OnceLock<std::sync::Arc<tokio::sync::Mutex<()>>> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
/// Acquires the process-local guard shared by optional real PostgreSQL tests.
|
||||
pub(in crate::postgres) async fn postgres_test_guard() -> tokio::sync::OwnedMutexGuard<()> {
|
||||
let mutex = POSTGRES_TEST_MUTEX
|
||||
.get_or_init(|| return std::sync::Arc::new(tokio::sync::Mutex::new(())))
|
||||
.clone();
|
||||
return mutex.lock_owned().await;
|
||||
}
|
||||
Reference in New Issue
Block a user