v0.3.3-pre.007
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-store-postgres-lib/README.md -->
|
||||
<!-- version: 5 -->
|
||||
<!-- version: 6 -->
|
||||
|
||||
# ksp-store-postgres-lib
|
||||
|
||||
@@ -83,7 +83,7 @@ ksp_store_schema_migrations
|
||||
|
||||
Le moteur vérifie version, nom et checksum SHA-256, sérialise les runners par advisory transaction lock et refuse une history divergente ou plus récente que le runtime.
|
||||
|
||||
V001 possède désormais le schéma physique `RawTransaction` et son contrat de compatibilité. Les lectures exactes sont acquises depuis `pre.004`; `pre.005` ajoute les écritures atomiques transaction + observation, l'idempotence réelle et la classification de conflit. `pre.006` ajoute la navigation keyset déterministe sur l'index `(slot, signature)` et son cursor opaque lié à la requête.
|
||||
V001 possède désormais le schéma physique `RawTransaction` et son contrat de compatibilité. Les lectures exactes sont acquises depuis `pre.004`; `pre.005` ajoute les écritures atomiques transaction + observation, l'idempotence réelle et la classification de conflit. `pre.006` ajoute la navigation keyset déterministe sur l'index `(slot, signature)` et son cursor opaque lié à la requête. `pre.007` ajoute les transitions de rétention atomiques `Full -> Archived -> Purged` et le rejet explicite de `Compacted` tant qu'aucune représentation compacte réelle n'existe.
|
||||
|
||||
## Health et erreurs
|
||||
|
||||
@@ -147,13 +147,26 @@ La continuation est une keyset stricte `>` / `<`, jamais un `OFFSET`. Le cursor
|
||||
|
||||
`RawPageLimit` n'est pas réduit par une policy KSP : PostgreSQL utilise `LIMIT requested + 1`, avec comme seule borne physique `requested <= i64::MAX - 1`. Une valeur supérieure produit `PageLimitUnsupported` sans clamp. La pagination ne promet aucun snapshot inter-pages.
|
||||
|
||||
## Rétention RAW `0.3.3-pre.007`
|
||||
|
||||
`transition_raw_transaction_retention` applique uniquement les transitions physiques supportées :
|
||||
|
||||
```text
|
||||
Full -> Archived -> Purged
|
||||
```
|
||||
|
||||
Le backend verrouille la ligne canonique avec `FOR UPDATE`, valide la forme physique courante, compare `current` avec `expected` et n'effectue la mutation qu'en cas de correspondance. `current == target` retourne `AlreadyAtTarget`; une race ayant déplacé l'état ailleurs retourne `ExpectedStateMismatch`. Une référence absente est `ReferenceNotFound`.
|
||||
|
||||
`Full -> Archived` copie d'abord les octets exacts dans `ksp_raw_transaction_archive_payloads`, puis retire le payload chaud et passe l'état à `archived` dans la même transaction. `Archived -> Purged` supprime l'archive, efface `block_time`, conserve les cinq champs de tombstone et passe l'état à `purged` atomiquement. Les écritures d'acquisition/rehydration et les transitions utilisent le même verrou canonique, ce qui sérialise les races purge/ForceRehydrate.
|
||||
|
||||
Toute transition dont `expected` ou `target` vaut `Compacted` est rejetée avant `pool.get()` avec `RetentionCompactionUnsupported` et le code stable `store.postgres_retention_compaction_unsupported`. PostgreSQL n'utilise pas TOAST comme faux contrat de compaction.
|
||||
|
||||
## Hors périmètre actuel
|
||||
|
||||
La crate ne contient encore :
|
||||
|
||||
- aucune pagination/listing `RawTransaction` ;
|
||||
- aucune implémentation complète des traits `RawTransaction*` de `ksp-store-api` tant que `list_raw_transactions` manque ;
|
||||
- aucune transition mutante de rétention ;
|
||||
- aucune implémentation complète des six traits `RawTransaction*` de `ksp-store-api` ;
|
||||
- aucun dispatch métier dans `ksp-store-lib` ;
|
||||
- aucune implémentation PostgreSQL des capabilities `RawAccount*` ;
|
||||
- aucune orchestration worker/job ;
|
||||
- aucun transport d'acquisition ou decoder Program.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-store-postgres-lib/USAGE.md -->
|
||||
<!-- version: 5 -->
|
||||
<!-- version: 6 -->
|
||||
|
||||
# Utilisation de ksp-store-postgres-lib
|
||||
|
||||
@@ -133,6 +133,7 @@ match error.kind() {
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::QueryInvalid => {}
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::ReadFailed => {}
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::ReferenceNotFound => {}
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::RetentionCompactionUnsupported => {}
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::SchemaNewer => {}
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::ShutdownTimeout => {}
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::TlsFailed => {}
|
||||
@@ -204,13 +205,27 @@ Le cursor peut être transmis uniquement à une query ayant le même binding. Un
|
||||
|
||||
La seule limite physique est liée au `LIMIT + 1` PostgreSQL : `requested <= i64::MAX - 1`. Au-delà, `PageLimitUnsupported` est retourné ; la demande n'est jamais ramenée à 100, 500, 1000 ou une autre policy de worker.
|
||||
|
||||
## 10. Ce que cette crate ne permet pas encore
|
||||
## 10. Appliquer une transition de rétention
|
||||
|
||||
Depuis `0.3.3-pre.007`, le backend expose :
|
||||
|
||||
```rust
|
||||
let outcome = backend.transition_raw_transaction_retention(transition).await;
|
||||
```
|
||||
|
||||
Le backend ne choisit jamais lui-même la policy de rétention. Le caller fournit un `RawTransactionRetentionTransition` avec `expected` et `target`; PostgreSQL verrouille le canonical avec `FOR UPDATE`, puis retourne `Applied`, `AlreadyAtTarget` ou `ExpectedStateMismatch` selon l'état réellement observé.
|
||||
|
||||
Les transitions physiques supportées sont `Full -> Archived` puis `Archived -> Purged`. L'archivage copie le payload exact vers la relation archive avant de retirer les octets chauds, et la purge supprime cette archive puis efface le block time en conservant uniquement le tombstone minimal. Tout est transactionnel : aucun état intermédiaire n'est committé.
|
||||
|
||||
Une transition impliquant `Compacted` est rejetée avant acquisition du pool avec `PostgresBackendErrorKind::RetentionCompactionUnsupported`. Le code KSP correspondant est `ERROR_CODE_POSTGRES_RETENTION_COMPACTION_UNSUPPORTED`; aucune compression transparente PostgreSQL n'est présentée comme une représentation compactée KSP.
|
||||
|
||||
## 11. Ce que cette crate ne permet pas encore
|
||||
|
||||
La tranche ne fournit pas encore :
|
||||
|
||||
```text
|
||||
transitions de rétention
|
||||
implémentations complètes des six traits RawTransaction*
|
||||
dispatch métier ksp-store-lib
|
||||
capabilities RawAccount*
|
||||
```
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-store-postgres-lib/src/error.rs
|
||||
// version: 7
|
||||
// version: 8
|
||||
|
||||
/// Stable KSP error code reserved for PostgreSQL retention transitions that require unsupported physical compaction.
|
||||
pub const ERROR_CODE_POSTGRES_RETENTION_COMPACTION_UNSUPPORTED: ksp_store_api::ErrorCode =
|
||||
@@ -33,6 +33,8 @@ pub enum PostgresBackendErrorKind {
|
||||
ReadFailed,
|
||||
/// A RAW write requires an existing canonical reference that is not durable.
|
||||
ReferenceNotFound,
|
||||
/// The requested RAW retention transition requires a compacted representation unsupported by PostgreSQL.
|
||||
RetentionCompactionUnsupported,
|
||||
/// The database schema history contains a migration newer than this runtime understands.
|
||||
SchemaNewer,
|
||||
/// Explicit backend shutdown did not drain inside the supplied deadline.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-store-postgres-lib/src/lib.rs
|
||||
// version: 12
|
||||
// version: 13
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
@@ -17,6 +17,8 @@
|
||||
//! checks and safe conflict classification without exposing PostgreSQL rows or
|
||||
//! SQL through the public bridge. `0.3.3-pre.006` adds deterministic keyset
|
||||
//! pagination with a fixed opaque cursor bound to network, range and direction.
|
||||
//! `0.3.3-pre.007` adds atomic `Full -> Archived -> Purged` retention transitions
|
||||
//! with compare-and-transition outcomes and explicit rejection of `Compacted`.
|
||||
//!
|
||||
//! This crate depends on `ksp-store-api` and never on `ksp-store-lib`. The
|
||||
//! common facade consumes only this crate's narrow backend bridge and never
|
||||
@@ -75,6 +77,8 @@ pub(crate) use self::raw_transaction::list_raw_transactions;
|
||||
pub(crate) use self::raw_transaction::persist_raw_transaction_acquisition;
|
||||
/// Private additional RAW transaction observation writer consumed by the physical backend runtime.
|
||||
pub(crate) use self::raw_transaction::record_raw_transaction_observation;
|
||||
/// Private RAW transaction retention transition writer consumed by the physical backend runtime.
|
||||
pub(crate) use self::raw_transaction::transition_raw_transaction_retention;
|
||||
/// Private Deadpool error mapper shared with the health probe.
|
||||
pub(crate) use self::runtime::map_pool_error;
|
||||
/// Private Deadpool status projector shared with the health probe.
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
// file: crates/ksp-store-postgres-lib/src/raw_transaction.rs
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
pub(crate) mod cursor;
|
||||
|
||||
const DELETE_ARCHIVE_PAYLOAD_SQL: &str = "DELETE FROM ksp_raw_transaction_archive_payloads WHERE signature = $1";
|
||||
const GET_ARCHIVE_PAYLOAD_SQL: &str = "SELECT payload FROM ksp_raw_transaction_archive_payloads WHERE signature = $1";
|
||||
const GET_OBSERVATION_SQL: &str = "SELECT observation_key, transaction_signature, provider, protocol, acquisition_method, origin, received_at_unix_millis, capture_session_id, commitment, endpoint_id, filter_id, observed_at_unix_millis, source_payload_hash, source_payload_size_bytes FROM ksp_raw_transaction_observations WHERE observation_key = $1";
|
||||
const GET_RETENTION_SQL: &str = "SELECT retention_state FROM ksp_raw_transactions WHERE signature = $1";
|
||||
const GET_TOMBSTONE_SQL: &str = "SELECT signature, slot::text AS slot_text, block_time_unix_millis, format_id, format_version, content_hash, retention_state FROM ksp_raw_transactions WHERE signature = $1";
|
||||
const GET_TRANSACTION_SQL: &str = "SELECT transaction_row.signature, transaction_row.slot::text AS slot_text, transaction_row.block_time_unix_millis, transaction_row.format_id, transaction_row.format_version, transaction_row.content_hash, transaction_row.payload, transaction_row.retention_state, archive_row.payload AS archive_payload FROM ksp_raw_transactions AS transaction_row LEFT JOIN ksp_raw_transaction_archive_payloads AS archive_row ON archive_row.signature = transaction_row.signature WHERE transaction_row.signature = $1";
|
||||
const INSERT_ARCHIVE_PAYLOAD_SQL: &str = "INSERT INTO ksp_raw_transaction_archive_payloads (signature, payload) VALUES ($1, $2)";
|
||||
const INSERT_OBSERVATION_SQL: &str = "INSERT INTO ksp_raw_transaction_observations (observation_key, transaction_signature, provider, protocol, acquisition_method, origin, received_at_unix_millis, capture_session_id, commitment, endpoint_id, filter_id, observed_at_unix_millis, source_payload_hash, source_payload_size_bytes) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) ON CONFLICT (observation_key) DO NOTHING RETURNING observation_key";
|
||||
const INSERT_TRANSACTION_SQL: &str = "INSERT INTO ksp_raw_transactions (signature, slot, block_time_unix_millis, format_id, format_version, content_hash, payload, retention_state) VALUES ($1, $2::TEXT::NUMERIC, $3, $4, $5, $6, $7, 'full') ON CONFLICT (signature) DO NOTHING RETURNING signature";
|
||||
const LIST_TRANSACTIONS_ASC_SQL: &str = "SELECT signature, slot::text AS slot_text FROM ksp_raw_transactions WHERE retention_state <> 'purged' AND ($1::TEXT IS NULL OR slot >= $1::TEXT::NUMERIC) AND ($2::TEXT IS NULL OR slot <= $2::TEXT::NUMERIC) AND ($3::TEXT IS NULL OR (slot, signature) > ($3::TEXT::NUMERIC, $4::BYTEA)) ORDER BY slot ASC, signature ASC LIMIT $5";
|
||||
const LIST_TRANSACTIONS_DESC_SQL: &str = "SELECT signature, slot::text AS slot_text FROM ksp_raw_transactions WHERE retention_state <> 'purged' AND ($1::TEXT IS NULL OR slot >= $1::TEXT::NUMERIC) AND ($2::TEXT IS NULL OR slot <= $2::TEXT::NUMERIC) AND ($3::TEXT IS NULL OR (slot, signature) < ($3::TEXT::NUMERIC, $4::BYTEA)) ORDER BY slot DESC, signature DESC LIMIT $5";
|
||||
const LOCK_OBSERVATION_SQL: &str = "SELECT observation_key, transaction_signature, provider, protocol, acquisition_method, origin, received_at_unix_millis, capture_session_id, commitment, endpoint_id, filter_id, observed_at_unix_millis, source_payload_hash, source_payload_size_bytes FROM ksp_raw_transaction_observations WHERE observation_key = $1 FOR UPDATE";
|
||||
const LOCK_RETENTION_TRANSACTION_SQL: &str =
|
||||
"SELECT block_time_unix_millis, payload, retention_state FROM ksp_raw_transactions WHERE signature = $1 FOR UPDATE";
|
||||
const LOCK_TRANSACTION_SQL: &str = "SELECT signature, slot::text AS slot_text, block_time_unix_millis, format_id, format_version, content_hash, payload, retention_state, NULL::BYTEA AS archive_payload FROM ksp_raw_transactions WHERE signature = $1 FOR UPDATE";
|
||||
const LOCK_TRANSACTION_STATE_SQL: &str = "SELECT retention_state FROM ksp_raw_transactions WHERE signature = $1 FOR UPDATE";
|
||||
const REHYDRATE_TRANSACTION_SQL: &str =
|
||||
"UPDATE ksp_raw_transactions SET block_time_unix_millis = $2, payload = $3, retention_state = 'full' WHERE signature = $1";
|
||||
const UPDATE_ARCHIVED_TRANSACTION_SQL: &str =
|
||||
"UPDATE ksp_raw_transactions SET payload = NULL, retention_state = 'archived' WHERE signature = $1 AND retention_state = 'full'";
|
||||
const UPDATE_PURGED_TRANSACTION_SQL: &str = "UPDATE ksp_raw_transactions SET block_time_unix_millis = NULL, payload = NULL, retention_state = 'purged' WHERE signature = $1 AND retention_state = 'archived'";
|
||||
|
||||
struct RawListDbRow {
|
||||
signature: std::vec::Vec<u8>,
|
||||
@@ -40,6 +47,12 @@ struct RawObservationDbRow {
|
||||
transaction_signature: std::vec::Vec<u8>,
|
||||
}
|
||||
|
||||
struct RawRetentionDbRow {
|
||||
block_time_unix_millis: std::option::Option<i64>,
|
||||
payload: std::option::Option<std::vec::Vec<u8>>,
|
||||
retention_state: std::string::String,
|
||||
}
|
||||
|
||||
struct RawTransactionDbRow {
|
||||
archive_payload: std::option::Option<std::vec::Vec<u8>>,
|
||||
block_time_unix_millis: std::option::Option<i64>,
|
||||
@@ -450,12 +463,250 @@ pub(crate) async fn record_raw_transaction_observation(
|
||||
return std::result::Result::Ok(outcome);
|
||||
}
|
||||
|
||||
/// Applies one atomic compare-and-transition RAW transaction retention mutation.
|
||||
pub(crate) async fn transition_raw_transaction_retention(
|
||||
pool: &deadpool_postgres::Pool,
|
||||
network: &ksp_store_api::RawNetworkId,
|
||||
transition: ksp_store_api::RawTransactionRetentionTransition,
|
||||
) -> std::result::Result<ksp_store_api::RawRetentionWriteOutcome, crate::PostgresBackendError> {
|
||||
let input_result = ensure_retention_transition_inputs(network, &transition);
|
||||
if let std::result::Result::Err(error) = input_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let client_result = pool.get().await;
|
||||
let mut client = match client_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(crate::map_pool_error(error)),
|
||||
};
|
||||
let sql_transaction_result = client.transaction().await;
|
||||
let sql_transaction = match sql_transaction_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(write_failed("raw_retention_begin")),
|
||||
};
|
||||
let signature = transition.reference().signature();
|
||||
let signature_bytes: &[u8] = signature.as_bytes();
|
||||
let row_result = sql_transaction.query_opt(LOCK_RETENTION_TRANSACTION_SQL, &[&signature_bytes]).await;
|
||||
let row = match row_result {
|
||||
std::result::Result::Ok(std::option::Option::Some(value)) => value,
|
||||
std::result::Result::Ok(std::option::Option::None) => return std::result::Result::Err(reference_not_found("raw_retention_transition_reference")),
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(write_failed("raw_retention_lock_transaction")),
|
||||
};
|
||||
let physical = match raw_retention_db_row(&row) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let current = match decode_retention_state(physical.retention_state.as_str()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let shape_result = validate_retention_shape(&sql_transaction, signature_bytes, &physical, current).await;
|
||||
if let std::result::Result::Err(error) = shape_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let decision = match retention_transition_decision(current, transition.expected(), transition.target()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
match decision {
|
||||
RetentionTransitionDecision::AlreadyAtTarget => {
|
||||
return commit_retention_outcome(sql_transaction, ksp_store_api::RawRetentionWriteOutcome::AlreadyAtTarget).await;
|
||||
},
|
||||
RetentionTransitionDecision::ExpectedStateMismatch => {
|
||||
return commit_retention_outcome(sql_transaction, ksp_store_api::RawRetentionWriteOutcome::ExpectedStateMismatch).await;
|
||||
},
|
||||
RetentionTransitionDecision::Archive => {
|
||||
let payload = match physical.payload.as_ref() {
|
||||
std::option::Option::Some(value) => value.as_slice(),
|
||||
std::option::Option::None => return std::result::Result::Err(data_invalid("raw_retention_full_payload")),
|
||||
};
|
||||
let archive_result = archive_full_transaction(&sql_transaction, signature_bytes, payload).await;
|
||||
if let std::result::Result::Err(error) = archive_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
},
|
||||
RetentionTransitionDecision::Purge => {
|
||||
let purge_result = purge_archived_transaction(&sql_transaction, signature_bytes).await;
|
||||
if let std::result::Result::Err(error) = purge_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
},
|
||||
}
|
||||
return commit_retention_outcome(sql_transaction, ksp_store_api::RawRetentionWriteOutcome::Applied).await;
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum ExistingTransactionMatch {
|
||||
Active,
|
||||
Purged,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum RetentionTransitionDecision {
|
||||
AlreadyAtTarget,
|
||||
Archive,
|
||||
ExpectedStateMismatch,
|
||||
Purge,
|
||||
}
|
||||
|
||||
async fn archive_full_transaction(
|
||||
sql_transaction: &deadpool_postgres::Transaction<'_>,
|
||||
signature_bytes: &[u8],
|
||||
payload: &[u8],
|
||||
) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||
let insert_result = sql_transaction.execute(INSERT_ARCHIVE_PAYLOAD_SQL, &[&signature_bytes, &payload]).await;
|
||||
match insert_result {
|
||||
std::result::Result::Ok(1) => {},
|
||||
std::result::Result::Ok(_) => return std::result::Result::Err(data_invalid("raw_retention_archive_insert_cardinality")),
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(write_failed("raw_retention_archive_insert")),
|
||||
}
|
||||
let update_result = sql_transaction.execute(UPDATE_ARCHIVED_TRANSACTION_SQL, &[&signature_bytes]).await;
|
||||
return match update_result {
|
||||
std::result::Result::Ok(1) => std::result::Result::Ok(()),
|
||||
std::result::Result::Ok(_) => std::result::Result::Err(data_invalid("raw_retention_archive_update_cardinality")),
|
||||
std::result::Result::Err(_) => std::result::Result::Err(write_failed("raw_retention_archive_update")),
|
||||
};
|
||||
}
|
||||
|
||||
async fn commit_retention_outcome(
|
||||
sql_transaction: deadpool_postgres::Transaction<'_>,
|
||||
outcome: ksp_store_api::RawRetentionWriteOutcome,
|
||||
) -> std::result::Result<ksp_store_api::RawRetentionWriteOutcome, crate::PostgresBackendError> {
|
||||
let commit_result = sql_transaction.commit().await;
|
||||
if commit_result.is_err() {
|
||||
return std::result::Result::Err(write_failed("raw_retention_commit"));
|
||||
}
|
||||
return std::result::Result::Ok(outcome);
|
||||
}
|
||||
|
||||
async fn load_retention_archive_payload(
|
||||
sql_transaction: &deadpool_postgres::Transaction<'_>,
|
||||
signature_bytes: &[u8],
|
||||
) -> std::result::Result<std::option::Option<std::vec::Vec<u8>>, crate::PostgresBackendError> {
|
||||
let row_result = sql_transaction.query_opt(GET_ARCHIVE_PAYLOAD_SQL, &[&signature_bytes]).await;
|
||||
let row = match row_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(write_failed("raw_retention_archive_query")),
|
||||
};
|
||||
let archive_row = match row {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Ok(std::option::Option::None),
|
||||
};
|
||||
let payload_result = archive_row.try_get::<_, std::vec::Vec<u8>>("payload");
|
||||
return match payload_result {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(std::option::Option::Some(value)),
|
||||
std::result::Result::Err(_) => std::result::Result::Err(data_invalid("raw_retention_archive_decode")),
|
||||
};
|
||||
}
|
||||
|
||||
async fn purge_archived_transaction(
|
||||
sql_transaction: &deadpool_postgres::Transaction<'_>,
|
||||
signature_bytes: &[u8],
|
||||
) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||
let delete_result = sql_transaction.execute(DELETE_ARCHIVE_PAYLOAD_SQL, &[&signature_bytes]).await;
|
||||
match delete_result {
|
||||
std::result::Result::Ok(1) => {},
|
||||
std::result::Result::Ok(_) => return std::result::Result::Err(data_invalid("raw_retention_purge_archive_cardinality")),
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(write_failed("raw_retention_purge_archive")),
|
||||
}
|
||||
let update_result = sql_transaction.execute(UPDATE_PURGED_TRANSACTION_SQL, &[&signature_bytes]).await;
|
||||
return match update_result {
|
||||
std::result::Result::Ok(1) => std::result::Result::Ok(()),
|
||||
std::result::Result::Ok(_) => std::result::Result::Err(data_invalid("raw_retention_purge_update_cardinality")),
|
||||
std::result::Result::Err(_) => std::result::Result::Err(write_failed("raw_retention_purge_update")),
|
||||
};
|
||||
}
|
||||
|
||||
fn raw_retention_db_row(row: &tokio_postgres::Row) -> std::result::Result<RawRetentionDbRow, crate::PostgresBackendError> {
|
||||
let block_time_unix_millis = match row.try_get::<_, std::option::Option<i64>>("block_time_unix_millis") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(data_invalid("raw_retention_transition_decode")),
|
||||
};
|
||||
let payload = match row.try_get::<_, std::option::Option<std::vec::Vec<u8>>>("payload") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(data_invalid("raw_retention_transition_decode")),
|
||||
};
|
||||
let retention_state = match row.try_get::<_, std::string::String>("retention_state") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::result::Result::Err(data_invalid("raw_retention_transition_decode")),
|
||||
};
|
||||
return std::result::Result::Ok(RawRetentionDbRow { block_time_unix_millis, payload, retention_state });
|
||||
}
|
||||
|
||||
fn retention_transition_decision(
|
||||
current: ksp_store_api::RawRetentionState,
|
||||
expected: ksp_store_api::RawRetentionState,
|
||||
target: ksp_store_api::RawRetentionState,
|
||||
) -> std::result::Result<RetentionTransitionDecision, crate::PostgresBackendError> {
|
||||
if current == target {
|
||||
return std::result::Result::Ok(RetentionTransitionDecision::AlreadyAtTarget);
|
||||
}
|
||||
if current != expected {
|
||||
return std::result::Result::Ok(RetentionTransitionDecision::ExpectedStateMismatch);
|
||||
}
|
||||
return match (expected, target) {
|
||||
(ksp_store_api::RawRetentionState::Full, ksp_store_api::RawRetentionState::Archived) => std::result::Result::Ok(RetentionTransitionDecision::Archive),
|
||||
(ksp_store_api::RawRetentionState::Archived, ksp_store_api::RawRetentionState::Purged) => std::result::Result::Ok(RetentionTransitionDecision::Purge),
|
||||
(ksp_store_api::RawRetentionState::Full, ksp_store_api::RawRetentionState::Compacted)
|
||||
| (ksp_store_api::RawRetentionState::Compacted, ksp_store_api::RawRetentionState::Archived) => {
|
||||
std::result::Result::Err(retention_compaction_unsupported("raw_retention_compaction"))
|
||||
},
|
||||
_ => std::result::Result::Err(data_invalid("raw_retention_transition_unreachable")),
|
||||
};
|
||||
}
|
||||
|
||||
async fn validate_retention_shape(
|
||||
sql_transaction: &deadpool_postgres::Transaction<'_>,
|
||||
signature_bytes: &[u8],
|
||||
row: &RawRetentionDbRow,
|
||||
state: ksp_store_api::RawRetentionState,
|
||||
) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||
let archive_payload = match load_retention_archive_payload(sql_transaction, signature_bytes).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return match state {
|
||||
ksp_store_api::RawRetentionState::Full => {
|
||||
let payload = match row.payload.as_ref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(data_invalid("raw_retention_full_shape")),
|
||||
};
|
||||
let payload_result = validate_retained_payload_bytes(payload.as_slice(), "raw_retention_full_payload");
|
||||
if let std::result::Result::Err(error) = payload_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if archive_payload.is_some() {
|
||||
return std::result::Result::Err(data_invalid("raw_retention_full_archive_residue"));
|
||||
}
|
||||
std::result::Result::Ok(())
|
||||
},
|
||||
ksp_store_api::RawRetentionState::Archived => {
|
||||
if row.payload.is_some() {
|
||||
return std::result::Result::Err(data_invalid("raw_retention_archived_hot_payload"));
|
||||
}
|
||||
let payload = match archive_payload.as_ref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::result::Result::Err(data_invalid("raw_retention_archived_payload_missing")),
|
||||
};
|
||||
validate_retained_payload_bytes(payload.as_slice(), "raw_retention_archived_payload")
|
||||
},
|
||||
ksp_store_api::RawRetentionState::Purged => {
|
||||
if row.payload.is_some() || archive_payload.is_some() || row.block_time_unix_millis.is_some() {
|
||||
return std::result::Result::Err(data_invalid("raw_retention_purged_shape"));
|
||||
}
|
||||
std::result::Result::Ok(())
|
||||
},
|
||||
ksp_store_api::RawRetentionState::Compacted => std::result::Result::Err(retention_compaction_unsupported("raw_retention_compaction")),
|
||||
_ => std::result::Result::Err(data_invalid("raw_retention_state")),
|
||||
};
|
||||
}
|
||||
|
||||
fn validate_retained_payload_bytes(payload: &[u8], phase: &'static str) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||
if payload.is_empty() || payload.len() > ksp_store_api::MAX_RAW_PAYLOAD_BYTES {
|
||||
return std::result::Result::Err(data_invalid(phase));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
async fn insert_canonical_transaction(
|
||||
sql_transaction: &deadpool_postgres::Transaction<'_>,
|
||||
raw_transaction: &ksp_store_api::RawTransaction,
|
||||
@@ -717,6 +968,20 @@ fn ensure_acquisition_inputs(
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn ensure_retention_transition_inputs(
|
||||
network: &ksp_store_api::RawNetworkId,
|
||||
transition: &ksp_store_api::RawTransactionRetentionTransition,
|
||||
) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||
let network_result = ensure_network(network, transition.reference(), "raw_retention_transition_network");
|
||||
if let std::result::Result::Err(error) = network_result {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if transition.expected() == ksp_store_api::RawRetentionState::Compacted || transition.target() == ksp_store_api::RawRetentionState::Compacted {
|
||||
return std::result::Result::Err(retention_compaction_unsupported("raw_retention_compaction"));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn encode_origin(origin: ksp_store_api::RawAcquisitionOrigin) -> std::result::Result<&'static str, crate::PostgresBackendError> {
|
||||
return match origin {
|
||||
ksp_store_api::RawAcquisitionOrigin::Backfill => std::result::Result::Ok("backfill"),
|
||||
@@ -732,6 +997,10 @@ fn conflict(phase: &'static str) -> crate::PostgresBackendError {
|
||||
return crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::Conflict, phase);
|
||||
}
|
||||
|
||||
fn retention_compaction_unsupported(phase: &'static str) -> crate::PostgresBackendError {
|
||||
return crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::RetentionCompactionUnsupported, phase);
|
||||
}
|
||||
|
||||
fn reference_not_found(phase: &'static str) -> crate::PostgresBackendError {
|
||||
return crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::ReferenceNotFound, phase);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-store-postgres-lib/src/runtime.rs
|
||||
// version: 8
|
||||
// version: 9
|
||||
|
||||
const APPLICATION_NAME: &str = "ksp-store";
|
||||
const MAX_CONNECTION_URI_BYTES: usize = 4_096;
|
||||
@@ -390,6 +390,14 @@ impl PostgresBackend {
|
||||
return crate::record_raw_transaction_observation(&self.pool, &self.network, observation).await;
|
||||
}
|
||||
|
||||
/// Applies one policy-authorized atomic RAW transaction retention transition.
|
||||
pub async fn transition_raw_transaction_retention(
|
||||
&self,
|
||||
transition: ksp_store_api::RawTransactionRetentionTransition,
|
||||
) -> std::result::Result<ksp_store_api::RawRetentionWriteOutcome, crate::PostgresBackendError> {
|
||||
return crate::transition_raw_transaction_retention(&self.pool, &self.network, transition).await;
|
||||
}
|
||||
|
||||
/// Explicitly closes the pool and waits for all owned pooled objects to drain inside the supplied bound.
|
||||
pub async fn close(self, timeout: std::time::Duration) -> std::result::Result<(), crate::PostgresBackendError> {
|
||||
self.pool.close();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-store-postgres-lib/tests/dependency_boundary.rs
|
||||
// version: 11
|
||||
// version: 12
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
@@ -166,12 +166,7 @@ fn pre_005_raw_write_sql_is_atomic_idempotent_and_keeps_later_scope_closed() {
|
||||
] {
|
||||
assert!(raw.contains(required), "missing pre.005 RAW write contract: {required}");
|
||||
}
|
||||
for forbidden in [
|
||||
"DELETE FROM",
|
||||
"transition_raw_transaction_retention",
|
||||
"impl ksp_store_api::RawTransactionWrite",
|
||||
"impl ksp_store_api::RawTransactionObservationWrite",
|
||||
] {
|
||||
for forbidden in ["DELETE FROM", "impl ksp_store_api::RawTransactionWrite", "impl ksp_store_api::RawTransactionObservationWrite"] {
|
||||
assert!(!raw.contains(forbidden), "pre.005 opened later RAW scope prematurely: {forbidden}");
|
||||
}
|
||||
return;
|
||||
@@ -215,9 +210,41 @@ fn pre_006_raw_pagination_is_keyset_cursor_bound_and_policy_free() {
|
||||
}
|
||||
assert!(index.contains("ON ksp_raw_transactions (slot, signature)"));
|
||||
assert!(index.contains("WHERE retention_state <> 'purged'"));
|
||||
for forbidden in [" OFFSET ", "limit.min(", "clamp(", "500", "1000", "DELETE FROM", "transition_raw_transaction_retention"] {
|
||||
for forbidden in [" OFFSET ", "limit.min(", "clamp(", "500", "1000"] {
|
||||
assert!(!raw.contains(forbidden), "pre.006 contains forbidden pagination/policy/later-scope material: {forbidden}");
|
||||
assert!(!cursor.contains(forbidden), "pre.006 cursor contains forbidden pagination/policy/later-scope material: {forbidden}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_007_raw_retention_is_atomic_compare_and_transition_without_fake_compaction() {
|
||||
let raw = include_str!("../src/raw_transaction.rs");
|
||||
let runtime = include_str!("../src/runtime.rs");
|
||||
for required in [
|
||||
"LOCK_RETENTION_TRANSACTION_SQL",
|
||||
"FOR UPDATE",
|
||||
"INSERT_ARCHIVE_PAYLOAD_SQL",
|
||||
"INSERT INTO ksp_raw_transaction_archive_payloads (signature, payload)",
|
||||
"UPDATE_ARCHIVED_TRANSACTION_SQL",
|
||||
"SET payload = NULL, retention_state = 'archived'",
|
||||
"DELETE_ARCHIVE_PAYLOAD_SQL",
|
||||
"DELETE FROM ksp_raw_transaction_archive_payloads",
|
||||
"UPDATE_PURGED_TRANSACTION_SQL",
|
||||
"SET block_time_unix_millis = NULL, payload = NULL, retention_state = 'purged'",
|
||||
"if current == target",
|
||||
"if current != expected",
|
||||
"RawRetentionWriteOutcome::AlreadyAtTarget",
|
||||
"RawRetentionWriteOutcome::ExpectedStateMismatch",
|
||||
"RawRetentionWriteOutcome::Applied",
|
||||
"RetentionCompactionUnsupported",
|
||||
"transition_raw_transaction_retention",
|
||||
] {
|
||||
assert!(raw.contains(required), "missing pre.007 retention contract: {required}");
|
||||
}
|
||||
assert!(runtime.contains("pub async fn transition_raw_transaction_retention"));
|
||||
for forbidden in ["retention_state = 'compacted'", "impl ksp_store_api::RawTransactionRetentionWrite", "flate", "zstd", "lz4", "snappy"] {
|
||||
assert!(!raw.contains(forbidden), "pre.007 contains fake compaction/direct-trait scope: {forbidden}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-store-postgres-lib/tests/public_api.rs
|
||||
// version: 7
|
||||
// version: 8
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
@@ -48,13 +48,14 @@ fn pre_005_backend_error_projection_is_safe_and_static() {
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::QueryInvalid,
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::ReadFailed,
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::ReferenceNotFound,
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::RetentionCompactionUnsupported,
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::SchemaNewer,
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::ShutdownTimeout,
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::TlsFailed,
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::WriteFailed,
|
||||
ksp_store_postgres_lib::PostgresBackendErrorKind::WrongNetwork,
|
||||
];
|
||||
assert_eq!(kinds.len(), 17);
|
||||
assert_eq!(kinds.len(), 18);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -95,3 +96,9 @@ fn pre_006_raw_list_bridge_uses_backend_independent_query_page_and_reference_mod
|
||||
let _list = ksp_store_postgres_lib::PostgresBackend::list_raw_transactions;
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_007_raw_retention_write_bridge_uses_backend_independent_transition_and_outcome_models() {
|
||||
let _transition = ksp_store_postgres_lib::PostgresBackend::transition_raw_transaction_retention;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-store-postgres-lib/unit_tests/raw_transaction.rs
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
fn network() -> ksp_store_api::RawNetworkId {
|
||||
return match ksp_store_api::RawNetworkId::new("devnet") {
|
||||
@@ -458,3 +458,114 @@ fn pre_006_page_limit_exposes_only_the_real_postgres_limit_plus_one_boundary() {
|
||||
assert_eq!(rejected.map(|value| return value.kind()), Some(crate::PostgresBackendErrorKind::PageLimitUnsupported));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_007_retention_transition_decision_matches_compare_and_transition_contract() {
|
||||
assert_eq!(
|
||||
super::retention_transition_decision(
|
||||
ksp_store_api::RawRetentionState::Full,
|
||||
ksp_store_api::RawRetentionState::Full,
|
||||
ksp_store_api::RawRetentionState::Archived,
|
||||
),
|
||||
std::result::Result::Ok(super::RetentionTransitionDecision::Archive),
|
||||
);
|
||||
assert_eq!(
|
||||
super::retention_transition_decision(
|
||||
ksp_store_api::RawRetentionState::Archived,
|
||||
ksp_store_api::RawRetentionState::Full,
|
||||
ksp_store_api::RawRetentionState::Archived,
|
||||
),
|
||||
std::result::Result::Ok(super::RetentionTransitionDecision::AlreadyAtTarget),
|
||||
);
|
||||
assert_eq!(
|
||||
super::retention_transition_decision(
|
||||
ksp_store_api::RawRetentionState::Archived,
|
||||
ksp_store_api::RawRetentionState::Archived,
|
||||
ksp_store_api::RawRetentionState::Purged,
|
||||
),
|
||||
std::result::Result::Ok(super::RetentionTransitionDecision::Purge),
|
||||
);
|
||||
assert_eq!(
|
||||
super::retention_transition_decision(
|
||||
ksp_store_api::RawRetentionState::Purged,
|
||||
ksp_store_api::RawRetentionState::Archived,
|
||||
ksp_store_api::RawRetentionState::Purged,
|
||||
),
|
||||
std::result::Result::Ok(super::RetentionTransitionDecision::AlreadyAtTarget),
|
||||
);
|
||||
assert_eq!(
|
||||
super::retention_transition_decision(
|
||||
ksp_store_api::RawRetentionState::Full,
|
||||
ksp_store_api::RawRetentionState::Archived,
|
||||
ksp_store_api::RawRetentionState::Purged,
|
||||
),
|
||||
std::result::Result::Ok(super::RetentionTransitionDecision::ExpectedStateMismatch),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_007_compacted_transitions_are_rejected_by_the_pre_io_guard() {
|
||||
let backend_network = network();
|
||||
let reference = ksp_store_api::RawTransactionReference::new(backend_network.clone(), ksp_store_api::RawTransactionSignature::new([5; 64]));
|
||||
let compact = match ksp_store_api::RawTransactionRetentionTransition::try_new(
|
||||
reference.clone(),
|
||||
ksp_store_api::RawRetentionState::Full,
|
||||
ksp_store_api::RawRetentionState::Compacted,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("valid logical compact transition rejected by API: {error:?}"),
|
||||
};
|
||||
let compact_error = super::ensure_retention_transition_inputs(&backend_network, &compact).err();
|
||||
assert_eq!(
|
||||
compact_error.as_ref().map(|value| return value.kind()),
|
||||
std::option::Option::Some(crate::PostgresBackendErrorKind::RetentionCompactionUnsupported),
|
||||
);
|
||||
assert_eq!(compact_error.as_ref().map(|value| return value.phase()), std::option::Option::Some("raw_retention_compaction"));
|
||||
let compact_to_archive = match ksp_store_api::RawTransactionRetentionTransition::try_new(
|
||||
reference,
|
||||
ksp_store_api::RawRetentionState::Compacted,
|
||||
ksp_store_api::RawRetentionState::Archived,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("valid logical compacted archive transition rejected by API: {error:?}"),
|
||||
};
|
||||
let compact_to_archive_error = super::ensure_retention_transition_inputs(&backend_network, &compact_to_archive).err();
|
||||
assert_eq!(
|
||||
compact_to_archive_error.as_ref().map(|value| return value.kind()),
|
||||
std::option::Option::Some(crate::PostgresBackendErrorKind::RetentionCompactionUnsupported),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_007_retention_transition_pre_io_guard_rejects_wrong_network() {
|
||||
let backend_network = network();
|
||||
let other_network = match ksp_store_api::RawNetworkId::new("mainnet-beta") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("valid alternate network rejected: {error:?}"),
|
||||
};
|
||||
let reference = ksp_store_api::RawTransactionReference::new(other_network, ksp_store_api::RawTransactionSignature::new([6; 64]));
|
||||
let transition = match ksp_store_api::RawTransactionRetentionTransition::try_new(
|
||||
reference,
|
||||
ksp_store_api::RawRetentionState::Full,
|
||||
ksp_store_api::RawRetentionState::Archived,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("valid retention transition rejected by API: {error:?}"),
|
||||
};
|
||||
let rejected = super::ensure_retention_transition_inputs(&backend_network, &transition);
|
||||
assert_eq!(rejected.err().map(|value| return value.kind()), std::option::Option::Some(crate::PostgresBackendErrorKind::WrongNetwork));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_007_retained_payload_shape_rejects_empty_and_oversized_bytes() {
|
||||
assert!(super::validate_retained_payload_bytes(&[1], "test_retention_payload").is_ok());
|
||||
let empty = super::validate_retained_payload_bytes(&[], "test_retention_payload");
|
||||
assert_eq!(empty.err().map(|value| return value.kind()), std::option::Option::Some(crate::PostgresBackendErrorKind::DataInvalid));
|
||||
let oversized = vec![0; ksp_store_api::MAX_RAW_PAYLOAD_BYTES + 1];
|
||||
let oversized = super::validate_retained_payload_bytes(oversized.as_slice(), "test_retention_payload");
|
||||
assert_eq!(oversized.err().map(|value| return value.kind()), std::option::Option::Some(crate::PostgresBackendErrorKind::DataInvalid));
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user