From aa56a12846b7171078bcdbc1c1af9ebed0513b71 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Sun, 30 Aug 2026 13:37:22 +0200 Subject: [PATCH] v0.3.3-pre.007 --- Cargo.toml | 4 +- crates/ksp-store-postgres-lib/README.md | 23 +- crates/ksp-store-postgres-lib/USAGE.md | 21 +- crates/ksp-store-postgres-lib/src/error.rs | 4 +- crates/ksp-store-postgres-lib/src/lib.rs | 6 +- .../src/raw_transaction.rs | 271 +++++++++++++++++- crates/ksp-store-postgres-lib/src/runtime.rs | 10 +- .../tests/dependency_boundary.rs | 43 ++- .../tests/public_api.rs | 11 +- .../unit_tests/raw_transaction.rs | 113 +++++++- deltas/0.3.3/pre.007.md | 158 ++++++++++ ...3_3_STORE_POSTGRES_RAW_TRANSACTION_PLAN.md | 26 +- ...0-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION.md | 34 ++- 13 files changed, 683 insertions(+), 41 deletions(-) create mode 100644 deltas/0.3.3/pre.007.md diff --git a/Cargo.toml b/Cargo.toml index 6bb0b0b..520cbe0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 353 +# version: 354 [workspace] resolver = "3" members = ["crates/ksp-app-config-desk", "crates/ksp-app-solprices-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-interface-lib", "crates/ksp-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-program-api", "crates/ksp-store-api", "crates/ksp-store-lib", "crates/ksp-store-postgres-lib", "crates/ksp-wallet-lib"] [workspace.package] -version = "0.3.3-pre.6.fix.1" +version = "0.3.3-pre.7" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-store-postgres-lib/README.md b/crates/ksp-store-postgres-lib/README.md index 1b586d8..8d752ed 100644 --- a/crates/ksp-store-postgres-lib/README.md +++ b/crates/ksp-store-postgres-lib/README.md @@ -1,5 +1,5 @@ - + # 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. diff --git a/crates/ksp-store-postgres-lib/USAGE.md b/crates/ksp-store-postgres-lib/USAGE.md index 64a0199..0486a51 100644 --- a/crates/ksp-store-postgres-lib/USAGE.md +++ b/crates/ksp-store-postgres-lib/USAGE.md @@ -1,5 +1,5 @@ - + # 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* ``` diff --git a/crates/ksp-store-postgres-lib/src/error.rs b/crates/ksp-store-postgres-lib/src/error.rs index c273092..70cba4f 100644 --- a/crates/ksp-store-postgres-lib/src/error.rs +++ b/crates/ksp-store-postgres-lib/src/error.rs @@ -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. diff --git a/crates/ksp-store-postgres-lib/src/lib.rs b/crates/ksp-store-postgres-lib/src/lib.rs index 1c38371..82ace4f 100644 --- a/crates/ksp-store-postgres-lib/src/lib.rs +++ b/crates/ksp-store-postgres-lib/src/lib.rs @@ -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. diff --git a/crates/ksp-store-postgres-lib/src/raw_transaction.rs b/crates/ksp-store-postgres-lib/src/raw_transaction.rs index e0ca9da..a0a3393 100644 --- a/crates/ksp-store-postgres-lib/src/raw_transaction.rs +++ b/crates/ksp-store-postgres-lib/src/raw_transaction.rs @@ -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, @@ -40,6 +47,12 @@ struct RawObservationDbRow { transaction_signature: std::vec::Vec, } +struct RawRetentionDbRow { + block_time_unix_millis: std::option::Option, + payload: std::option::Option>, + retention_state: std::string::String, +} + struct RawTransactionDbRow { archive_payload: std::option::Option>, block_time_unix_millis: std::option::Option, @@ -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 { + 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 { + 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>, 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>("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 { + let block_time_unix_millis = match row.try_get::<_, std::option::Option>("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>>("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 { + 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); } diff --git a/crates/ksp-store-postgres-lib/src/runtime.rs b/crates/ksp-store-postgres-lib/src/runtime.rs index 972a0d9..4e8b7ad 100644 --- a/crates/ksp-store-postgres-lib/src/runtime.rs +++ b/crates/ksp-store-postgres-lib/src/runtime.rs @@ -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 { + 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(); diff --git a/crates/ksp-store-postgres-lib/tests/dependency_boundary.rs b/crates/ksp-store-postgres-lib/tests/dependency_boundary.rs index b93936d..08dded5 100644 --- a/crates/ksp-store-postgres-lib/tests/dependency_boundary.rs +++ b/crates/ksp-store-postgres-lib/tests/dependency_boundary.rs @@ -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; +} diff --git a/crates/ksp-store-postgres-lib/tests/public_api.rs b/crates/ksp-store-postgres-lib/tests/public_api.rs index 8c76adf..593f238 100644 --- a/crates/ksp-store-postgres-lib/tests/public_api.rs +++ b/crates/ksp-store-postgres-lib/tests/public_api.rs @@ -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; +} diff --git a/crates/ksp-store-postgres-lib/unit_tests/raw_transaction.rs b/crates/ksp-store-postgres-lib/unit_tests/raw_transaction.rs index 2d0f358..ba9bc04 100644 --- a/crates/ksp-store-postgres-lib/unit_tests/raw_transaction.rs +++ b/crates/ksp-store-postgres-lib/unit_tests/raw_transaction.rs @@ -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; +} diff --git a/deltas/0.3.3/pre.007.md b/deltas/0.3.3/pre.007.md new file mode 100644 index 0000000..ddc6e8e --- /dev/null +++ b/deltas/0.3.3/pre.007.md @@ -0,0 +1,158 @@ + + + +# Delta `0.3.3-pre.007` — rétention atomique RawTransaction + +## 1. Base et gate d'entrée + +Base opérateur obligatoire : + +```text +0.3.3-pre.6.fix.1 +``` + +Le gate opérateur fourni le 2026-08-30 est entièrement vert : + +```text +cargo fmt --all PASS +scripts/audit_rust_workspace_rules.py PASS +scripts/audit_markdown_tables.py PASS +cargo check --workspace PASS +cargo clippy --workspace --all-targets PASS +cargo test -p ksp-store-api PASS +cargo test -p ksp-store-lib PASS +cargo test -p ksp-store-postgres-lib PASS (34 unit backend, live foundation ignoré) +cargo test -p ksp-config-lib PASS (128 unit + ownership/public API) +cargo check -p ksp-store-lib --no-default-features PASS +``` + +La pagination/cursor `pre.006` est donc acquise et la tranche peut ouvrir la rétention mutante. + +## 2. Version + +```text +workspace.package.version = 0.3.3-pre.7 +``` + +## 3. Scope exact + +Cette tranche ajoute uniquement le bridge backend PostgreSQL de transition : + +```text +PostgresBackend::transition_raw_transaction_retention +Full -> Archived +Archived -> Purged +compare-and-transition +Compacted unsupported +``` + +Ne sont pas encore ouverts : + +```text +implémentations complètes des six traits RawTransaction* +dispatch ksp-store-lib +RawAccountState +worker/job policy +``` + +## 4. Pré-I/O + +Avant `pool.get()` : + +- le réseau de `transition.reference()` doit égaler le binding backend ; +- si `expected` ou `target` vaut `Compacted`, le backend retourne `RetentionCompactionUnsupported` ; +- le code KSP stable reste `store.postgres_retention_compaction_unsupported`. + +Aucune ligne `retention_state = 'compacted'` n'est créée et aucune dépendance de compression n'est ajoutée. + +## 5. Compare-and-transition + +Sous transaction PostgreSQL, le canonical est lu avec `FOR UPDATE`. Sa forme physique est validée avant de retourner un outcome ou de muter : + +```text +current == target -> AlreadyAtTarget +current != expected -> ExpectedStateMismatch +Full + expected Full + target Archived -> archive +Archived + expected Archived + target Purged -> purge +``` + +Une référence absente est `ReferenceNotFound`. Aucun succès n'est rendu avant commit. + +## 6. `Full -> Archived` + +La transition : + +1. exige un payload hot non vide et borné ; +2. exige l'absence d'une archive résiduelle ; +3. insère les octets exacts dans `ksp_raw_transaction_archive_payloads` ; +4. met `payload = NULL` et `retention_state = 'archived'` sur le canonical ; +5. commit les deux opérations ensemble. + +Aucun état `Archived` sans payload archive n'est committé par KSP. + +## 7. `Archived -> Purged` + +La transition : + +1. exige l'absence de payload hot et la présence d'un payload archive valide ; +2. supprime la ligne archive ; +3. met `block_time_unix_millis = NULL`, `payload = NULL` et `retention_state = 'purged'` ; +4. conserve signature, slot, format id/version et content hash ; +5. commit atomiquement. + +Le tombstone reste donc limité aux cinq champs du contrat API. + +## 8. Races croisées + +Les acquisitions/idempotence/ForceRehydrate de `pre.005` et les transitions de cette tranche verrouillent la même ligne canonical avant mutation. Les races archive/purge/rehydrate sont donc sérialisées ; la preuve concurrente PostgreSQL réelle reste réservée à `pre.009`. + +## 9. Erreurs + +La classification backend ajoute : + +```text +RetentionCompactionUnsupported +``` + +Les autres classifications réutilisées sont : + +```text +WrongNetwork +ReferenceNotFound +DataInvalid +WriteFailed +``` + +Aucun texte serveur, SQLSTATE, query ou bind n'est retenu. + +## 10. Migrations + +Aucune ressource V000/V001 n'est modifiée. Les checksums attendus restent : + +```text +V000 d29068b8c13b9dc0cc9ef6aaadd0fa12d41e0fe4c56541a1118c4bfc846a1450 +V001 31488cda2f08f3f46c4cdbdbb6c18c243662fada02eac4487040c8735d72cc51 +``` + +## 11. Gate opérateur demandé + +```bash +cargo fmt --all +python3 scripts/audit_rust_workspace_rules.py +python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.3.3 +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test -p ksp-store-api +cargo test -p ksp-store-lib +cargo test -p ksp-store-postgres-lib +cargo test -p ksp-config-lib +cargo check -p ksp-store-lib --no-default-features +``` + +Le test PostgreSQL live reste `#[ignore]`; la preuve métier rétention/concurrence est réservée à `pre.009`. + +## 12. Suite si gate vert + +```text +0.3.3-pre.008 — six implémentations de capabilities + dispatch ksp-store-lib +``` diff --git a/docs/plans/024-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION_PLAN.md b/docs/plans/024-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION_PLAN.md index 324dda0..7b763c4 100644 --- a/docs/plans/024-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION_PLAN.md +++ b/docs/plans/024-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION_PLAN.md @@ -1,5 +1,5 @@ - + # Plan `0.3.3` — Store/PostgreSQL RawTransaction vertical slice @@ -31,15 +31,16 @@ Tranches techniques validées : 0.3.3-pre.003 + fix.001/fix.002/fix.003 — V001 physique, compatibilité de schéma et binding réseau 0.3.3-pre.004 — mapping et lectures RAW 0.3.3-pre.005 — écriture atomique et observations +0.3.3-pre.006 + fix.001 — pagination keyset + cursor V1 ``` Tranche technique courante : ```text -0.3.3-pre.006 — pagination keyset + cursor V1 +0.3.3-pre.007 — archive, purge, compare-and-transition et rejet Compacted ``` -Les gates opérateur jusqu'à `pre.005` sont verts. `pre.006` complète la surface backend-specific de lecture `RawTransaction` avec `list_raw_transactions`, sans ouvrir encore la rétention mutante, les implémentations complètes des six traits ni le dispatch dans `ksp-store-lib`. +Les gates opérateur jusqu'à `pre.006-fix.001` sont verts. `pre.007` complète la surface backend-specific `RawTransaction` avec les transitions physiques `Full -> Archived -> Purged`, sans implémenter encore les six traits ni leur dispatch dans `ksp-store-lib`. ## 2. Sources et autorité @@ -1131,12 +1132,19 @@ Tranche matérialisée : ### `0.3.3-pre.007` — archive, purge, tombstone, ForceRehydrate -- `Full -> Archived -> Purged` ; -- compare-and-transition ; -- archive relation ; -- normal skip purged ; -- force rehydrate ; -- rejet sûr et stable de `Compacted`, sans fausse compression. +Matérialisation : + +- `PostgresBackend::transition_raw_transaction_retention` ajouté sans implémentation directe de trait ; +- validation réseau et rejet de toute transition impliquant `Compacted` avant `pool.get()` ; +- `SELECT ... FOR UPDATE` du canonical avant toute décision/mutation ; +- `current == target` -> `AlreadyAtTarget` après validation de la forme physique ; +- `current != expected` -> `ExpectedStateMismatch` sans mutation ; +- `Full -> Archived` -> payload exact inséré dans la relation archive, puis payload hot `NULL` et état `archived`, dans la même transaction ; +- `Archived -> Purged` -> archive supprimée, block time effacé, état `purged`, tombstone minimal conservé ; +- unknown reference -> `ReferenceNotFound` ; +- `Compacted` -> `RetentionCompactionUnsupported` / `store.postgres_retention_compaction_unsupported` sans fausse compression ; +- acquisition normale et `ForceRehydrate` acquis en `pre.005` continuent d'utiliser le même verrou canonical et sont donc sérialisés avec archive/purge ; +- V000/V001 restent byte-identiques. ### `0.3.3-pre.008` — façade `ksp-store-lib` diff --git a/docs/validation/020-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION.md b/docs/validation/020-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION.md index 9ac99a5..0a1c4a1 100644 --- a/docs/validation/020-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION.md +++ b/docs/validation/020-V0_3_3_STORE_POSTGRES_RAW_TRANSACTION.md @@ -1,5 +1,5 @@ - + # Validation `0.3.3` — Store/PostgreSQL RawTransaction vertical slice @@ -594,12 +594,20 @@ cap 500/1000 dans Store pagination - `LIMIT requested + 1`, limite `i64::MAX - 1`, aucun cap worker : PASS unit/statique ; - index V001 `(slot, signature)` partiel réutilisé, migration inchangée : PASS statique ; - snapshot inter-pages explicitement non garanti : PASS doc ; -- gate Cargo opérateur : À EXÉCUTER. +- premier gate opérateur : FAIL local sur re-export cursor inutilisé + placeholder manquant du canari hardening ; +- `pre.006-fix.001` corrige uniquement ces deux défauts ; gate opérateur complet du 2026-08-30 : PASS (`check`, Clippy, Store/API/PostgreSQL/Config, no-default-features ; 34 tests unit backend). ### `pre.007` -- archive/purge/tombstone/ForceRehydrate ; -- Compacted unsupported stable. +- méthode backend-specific de transition : PASS statique ; +- network + `Compacted` rejetés pré-I/O : PASS unit design ; +- `Full -> Archived` et `Archived -> Purged` sous `FOR UPDATE` : PASS statique/unit decision ; +- `AlreadyAtTarget` / `ExpectedStateMismatch` / `Applied` : PASS unit decision ; +- archive payload/hot payload/block time validés selon l'état avant mutation/no-op : PASS statique ; +- unknown reference -> `ReferenceNotFound` : PASS statique ; +- `RetentionCompactionUnsupported` stable, aucune représentation `compacted` SQL : PASS statique/unit ; +- preuve des races et atomicité PostgreSQL réelle : différée à `pre.009` ; +- gate Cargo opérateur : À EXÉCUTER. ### `pre.008` @@ -631,7 +639,7 @@ cap 500/1000 dans Store pagination - publication stable. -## 22. Gate courant `pre.006` +## 22. Gate courant `pre.007` ```bash cargo fmt --all @@ -706,12 +714,24 @@ cargo check -p ksp-store-lib --no-default-features - [PASS] replay network/direction/range et cursor hostile classés `QueryInvalid` ; - [PASS] limite physique `requested <= i64::MAX - 1`, au-delà `PageLimitUnsupported` sans clamp ; - [PASS] aucune migration SQL modifiée, aucun scope rétention mutante/façade ouvert ; -- [À FAIRE] gate Cargo opérateur complet de `pre.006`. +- [PASS] premier gate a isolé deux défauts purement locaux, corrigés en `pre.006-fix.001`. ### `pre.006-fix.001` — correction du gate - [PASS] re-export crate-private `RawTransactionDecodedCursor` inutilisé supprimé sans modifier le codec cursor ; - [PASS] canari `hardening_completeness` corrigé pour concaténer les huit sources backend inspectées ; - [PASS] aucune logique SQL, pagination, migration ou checksum V000/V001 modifiée ; -- [À FAIRE] gate Cargo opérateur complet de `pre.006-fix.001`. +- [PASS] gate Cargo opérateur complet fourni le 2026-08-30. + +### `pre.007` — état de la tranche + +- [PASS] bridge backend-specific `transition_raw_transaction_retention` ajouté ; +- [PASS] validation réseau et `Compacted` avant pool I/O ; +- [PASS] compare-and-transition sérialisé par verrou canonical `FOR UPDATE` ; +- [PASS] `Full -> Archived` copie le payload exact vers archive puis retire le payload hot atomiquement ; +- [PASS] `Archived -> Purged` supprime l'archive, efface block time et conserve le tombstone minimal ; +- [PASS] `AlreadyAtTarget`, `ExpectedStateMismatch`, `Applied` décidés depuis l'état verrouillé ; +- [PASS] `RetentionCompactionUnsupported` utilise le code stable déjà acquis ; +- [PASS] aucune migration SQL modifiée, aucun trait Store complet ni dispatch façade ouvert ; +- [À FAIRE] gate Cargo opérateur complet de `pre.007`.