From 14f89262ab6a8717e851c9a00b4a0cff7674c80d Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Tue, 22 Sep 2026 07:40:23 +0200 Subject: [PATCH] 0.3.16-pre.008 --- Cargo.toml | 2 +- .../unit_tests/persistence.rs | 27 +- .../tests/hardening_completeness.rs | 5 +- .../tests/postgres_raw_transaction_live.rs | 176 ++++++++++++- .../tests/v003_variant_persistence.rs | 49 +++- .../unit_tests/runtime.rs | 46 +++- deltas/0.3.16/pre.008.md | 236 ++++++++++++++++++ ...38-V0_3_16_RAW_RESILIENCE_CONFLICT_PLAN.md | 12 +- 8 files changed, 533 insertions(+), 20 deletions(-) create mode 100644 deltas/0.3.16/pre.008.md diff --git a/Cargo.toml b/Cargo.toml index d2bea54..892a1d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ resolver = "3" members = ["crates/ksp-app-backfill-desk", "crates/ksp-app-config-desk", "crates/ksp-app-raw-transaction-ingest-desk", "crates/ksp-app-solprices-desk", "crates/ksp-app-store-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-interface-lib", "crates/ksp-job-api", "crates/ksp-job-backfill-lib", "crates/ksp-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-program-api", "crates/ksp-raw-transaction-lib", "crates/ksp-store-api", "crates/ksp-store-lib", "crates/ksp-store-postgres-lib", "crates/ksp-wallet-lib", "crates/ksp-worker-api", "crates/ksp-worker-raw-transaction-ingest-lib"] [workspace.package] -version = "0.3.16-pre.7.fix.3" +version = "0.3.16-pre.8" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-job-backfill-lib/unit_tests/persistence.rs b/crates/ksp-job-backfill-lib/unit_tests/persistence.rs index a346478..b00842d 100644 --- a/crates/ksp-job-backfill-lib/unit_tests/persistence.rs +++ b/crates/ksp-job-backfill-lib/unit_tests/persistence.rs @@ -1,5 +1,5 @@ // file: crates/ksp-job-backfill-lib/unit_tests/persistence.rs -// version: 2 +// version: 3 #[derive(Clone, Copy)] enum FakeResponse { @@ -299,6 +299,31 @@ async fn v0_3_16_pre_007_durable_quarantined_conflict_preserves_observation_proj return; } +#[tokio::test] +async fn v0_3_16_pre_008_reobserved_quarantined_conflict_preserves_idempotent_observation() { + let (reference, transaction, observation) = match raw_acquisition_parts("devnet", 6, 18) { + std::option::Option::Some(parts) => parts, + std::option::Option::None => return, + }; + let network = match raw_network("devnet") { + std::option::Option::Some(network) => network, + std::option::Option::None => return, + }; + let response = store_variant_outcome( + ksp_store_lib::RawEntityWriteOutcome::AlreadyPresent, + ksp_store_lib::RawObservationWriteOutcome::AlreadyPresent, + ksp_store_lib::RawTransactionVariantWriteOutcome::QuarantinedConflict, + ); + let port = FakePersistencePort::new(network, &[FakeResponse::Outcome(response)]); + let result = super::persist_available_with_port(&port, reference, transaction, observation).await; + assert!(result.is_ok()); + if let std::result::Result::Ok(result) = result { + assert_eq!(result.entity(), crate::BackfillEntityPersistence::Conflict); + assert_eq!(result.observation(), crate::BackfillObservationPersistence::AlreadyPresent); + } + return; +} + #[tokio::test] async fn pre_007_legacy_store_content_conflict_error_is_explicit_and_not_idempotent_success() { let (reference, transaction, observation) = match raw_acquisition_parts("devnet", 6, 16) { diff --git a/crates/ksp-store-postgres-lib/tests/hardening_completeness.rs b/crates/ksp-store-postgres-lib/tests/hardening_completeness.rs index b6f2c6a..d8f955d 100644 --- a/crates/ksp-store-postgres-lib/tests/hardening_completeness.rs +++ b/crates/ksp-store-postgres-lib/tests/hardening_completeness.rs @@ -1,5 +1,5 @@ // file: crates/ksp-store-postgres-lib/tests/hardening_completeness.rs -// version: 29 +// version: 30 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -284,6 +284,9 @@ fn pre_009_live_raw_transaction_proof_is_opt_in_isolated_and_secret_safe() { "prove_schema_update_policy", "prove_concurrent_identical_insert", "prove_concurrent_divergent_insert", + "prove_conflict_case_rollback", + "QuarantinedConflict", + "conflict_state_is_exact", "prove_pagination", "prove_inspection", "prove_retention_and_rehydrate", diff --git a/crates/ksp-store-postgres-lib/tests/postgres_raw_transaction_live.rs b/crates/ksp-store-postgres-lib/tests/postgres_raw_transaction_live.rs index 7b5c016..b4df433 100644 --- a/crates/ksp-store-postgres-lib/tests/postgres_raw_transaction_live.rs +++ b/crates/ksp-store-postgres-lib/tests/postgres_raw_transaction_live.rs @@ -1,5 +1,5 @@ // file: crates/ksp-store-postgres-lib/tests/postgres_raw_transaction_live.rs -// version: 9 +// version: 10 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -12,6 +12,68 @@ //! drops only the isolated schema it proved absent before the run. const LIVE_CANCEL_WAIT: std::time::Duration = std::time::Duration::from_millis(300); +const LIVE_CONFLICT_STATE_SQL: &str = r#"SELECT + (SELECT COUNT(*) = 2 FROM ksp_raw_transaction_variants WHERE transaction_signature = $1) + AND (SELECT COUNT(*) = 1 FROM ksp_raw_transaction_canonical_selectors WHERE transaction_signature = $1) + AND (SELECT COUNT(*) = 2 FROM ksp_raw_transaction_observations WHERE transaction_signature = $1) + AND (SELECT COUNT(*) = 2 FROM ksp_raw_transaction_observation_variants WHERE transaction_signature = $1) + AND EXISTS ( + SELECT 1 + FROM ksp_raw_transaction_conflicts AS conflict + INNER JOIN ksp_raw_transaction_canonical_selectors AS selector + ON selector.transaction_signature = conflict.transaction_signature + WHERE conflict.transaction_signature = $1 + AND conflict.status = 'open' + AND conflict.revision = 1 + AND conflict.canonical_variant_id = selector.canonical_variant_id + AND conflict.incoming_variant_id <> conflict.canonical_variant_id + ) + AND EXISTS ( + SELECT 1 + FROM ksp_raw_transactions AS canonical + INNER JOIN ksp_raw_transaction_canonical_selectors AS selector + ON selector.transaction_signature = canonical.signature + INNER JOIN ksp_raw_transaction_variants AS variant + ON variant.transaction_signature = selector.transaction_signature + AND variant.variant_id = selector.canonical_variant_id + WHERE canonical.signature = $1 + AND variant.slot = canonical.slot + AND variant.block_time_unix_millis IS NOT DISTINCT FROM canonical.block_time_unix_millis + AND variant.format_id = canonical.format_id + AND variant.format_version = canonical.format_version + AND variant.content_hash = canonical.content_hash + AND variant.payload = canonical.payload + ) + AND EXISTS ( + SELECT 1 FROM ksp_raw_transaction_observation_variants + WHERE observation_key = $2 AND transaction_signature = $1 + ) + AND EXISTS ( + SELECT 1 FROM ksp_raw_transaction_observation_variants + WHERE observation_key = $3 AND transaction_signature = $1 + )"#; +const LIVE_CONFLICT_ROLLBACK_STATE_SQL: &str = r#"SELECT + (SELECT COUNT(*) = 1 FROM ksp_raw_transaction_variants WHERE transaction_signature = $1) + AND (SELECT COUNT(*) = 1 FROM ksp_raw_transaction_canonical_selectors WHERE transaction_signature = $1) + AND (SELECT COUNT(*) = 0 FROM ksp_raw_transaction_conflicts WHERE transaction_signature = $1) + AND (SELECT COUNT(*) = 1 FROM ksp_raw_transaction_observations WHERE transaction_signature = $1) + AND (SELECT COUNT(*) = 1 FROM ksp_raw_transaction_observation_variants WHERE transaction_signature = $1) + AND EXISTS ( + SELECT 1 + FROM ksp_raw_transactions AS canonical + INNER JOIN ksp_raw_transaction_canonical_selectors AS selector + ON selector.transaction_signature = canonical.signature + INNER JOIN ksp_raw_transaction_variants AS variant + ON variant.transaction_signature = selector.transaction_signature + AND variant.variant_id = selector.canonical_variant_id + WHERE canonical.signature = $1 + AND variant.slot = canonical.slot + AND variant.block_time_unix_millis IS NOT DISTINCT FROM canonical.block_time_unix_millis + AND variant.format_id = canonical.format_id + AND variant.format_version = canonical.format_version + AND variant.content_hash = canonical.content_hash + AND variant.payload = canonical.payload + )"#; const LIVE_INDEX_EXISTS_SQL: &str = r#"SELECT EXISTS ( SELECT 1 FROM pg_indexes WHERE schemaname = current_schema() @@ -214,6 +276,10 @@ async fn run_raw_transaction_scenario(admin: &mut tokio_postgres::Client, uri: & if let std::result::Result::Err(error) = divergent_result { return std::result::Result::Err(error); } + let conflict_rollback_result = prove_conflict_case_rollback(admin, uri).await; + if let std::result::Result::Err(error) = conflict_rollback_result { + return std::result::Result::Err(error); + } let race_result = prove_retention_races(uri).await; if let std::result::Result::Err(error) = race_result { return std::result::Result::Err(error); @@ -414,23 +480,61 @@ async fn prove_concurrent_divergent_insert(admin: &tokio_postgres::Client, uri: }; let pair = [first_result, second_result]; let inserted = pair.iter().filter(|value| return matches_inserted(value)).count(); - let conflicts = pair - .iter() - .filter(|value| matches!(value, LivePersistResult::BackendError(ksp_store_postgres_lib::PostgresBackendErrorKind::Conflict))) - .count(); - if inserted != 1 || conflicts != 1 { + let quarantined = pair.iter().filter(|value| return matches_quarantined_conflict(value)).count(); + if inserted != 1 || quarantined != 1 { return std::result::Result::Err(LiveFailure::new("concurrent_divergent_outcome")); } + let reference = match raw_reference(30) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; let first_key = ksp_store_api::RawObservationKey::new([31; 32]); let second_key = ksp_store_api::RawObservationKey::new([32; 32]); - let first_exists = observation_exists(admin, &first_key).await; - let second_exists = observation_exists(admin, &second_key).await; - let count = match (first_exists, second_exists) { - (std::result::Result::Ok(first_value), std::result::Result::Ok(second_value)) => usize::from(first_value) + usize::from(second_value), - _ => return std::result::Result::Err(LiveFailure::new("concurrent_divergent_observation_probe")), + let state = conflict_state_is_exact(admin, &reference, &first_key, &second_key).await; + match state { + std::result::Result::Ok(true) => {}, + std::result::Result::Ok(false) => return std::result::Result::Err(LiveFailure::new("concurrent_divergent_state")), + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + return std::result::Result::Ok(()); +} + +async fn prove_conflict_case_rollback(admin: &tokio_postgres::Client, uri: &str) -> std::result::Result<(), LiveFailure> { + let seed = persist_once(uri.to_owned(), 82, 8_200, 82, 82).await; + match seed { + std::result::Result::Ok(LivePersistResult::Outcome(value)) if value.entity() == ksp_store_api::RawEntityWriteOutcome::Inserted => {}, + _ => return std::result::Result::Err(LiveFailure::new("conflict_rollback_seed")), + } + let backend = match open_backend(uri, "devnet", true, true).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), }; - if count != 1 { - return std::result::Result::Err(LiveFailure::new("concurrent_divergent_rollback")); + let transaction = match raw_transaction(82, 8_200, 83) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let observation = match raw_observation(10, 82, 83) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let result = backend.persist_raw_transaction_acquisition(transaction, observation, ksp_store_api::RawTransactionAcquisitionMode::Normal).await; + match result { + std::result::Result::Err(error) if error.kind() == ksp_store_postgres_lib::PostgresBackendErrorKind::Conflict => {}, + _ => return std::result::Result::Err(LiveFailure::new("conflict_rollback_expected_failure")), + } + let close_result = close_backend(backend).await; + if let std::result::Result::Err(error) = close_result { + return std::result::Result::Err(error); + } + let reference = match raw_reference(82) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let state = conflict_rollback_state_is_exact(admin, &reference).await; + match state { + std::result::Result::Ok(true) => {}, + std::result::Result::Ok(false) => return std::result::Result::Err(LiveFailure::new("conflict_rollback_state")), + std::result::Result::Err(error) => return std::result::Result::Err(error), } return std::result::Result::Ok(()); } @@ -971,6 +1075,16 @@ fn matches_inserted(value: &LivePersistResult) -> bool { ); } +fn matches_quarantined_conflict(value: &LivePersistResult) -> bool { + return matches!( + value, + LivePersistResult::Outcome(outcome) + if outcome.entity() == ksp_store_api::RawEntityWriteOutcome::AlreadyPresent + && outcome.observation() == ksp_store_api::RawObservationWriteOutcome::Inserted + && outcome.transaction_variant() == std::option::Option::Some(ksp_store_api::RawTransactionVariantWriteOutcome::QuarantinedConflict) + ); +} + fn matches_already_present(value: &LivePersistResult) -> bool { return matches!( value, @@ -1277,6 +1391,42 @@ async fn observation_exists(client: &tokio_postgres::Client, key: &ksp_store_api }; } +async fn conflict_state_is_exact( + client: &tokio_postgres::Client, + reference: &ksp_store_api::RawTransactionReference, + first_key: &ksp_store_api::RawObservationKey, + second_key: &ksp_store_api::RawObservationKey, +) -> std::result::Result { + let signature = reference.signature(); + let signature_bytes: &[u8] = signature.as_bytes(); + let first_key_bytes: &[u8] = first_key.as_bytes(); + let second_key_bytes: &[u8] = second_key.as_bytes(); + let row = match client.query_one(LIVE_CONFLICT_STATE_SQL, &[&signature_bytes, &first_key_bytes, &second_key_bytes]).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(LiveFailure::new("conflict_state_probe")), + }; + return match row.try_get::(0) { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(_) => std::result::Result::Err(LiveFailure::new("conflict_state_decode")), + }; +} + +async fn conflict_rollback_state_is_exact( + client: &tokio_postgres::Client, + reference: &ksp_store_api::RawTransactionReference, +) -> std::result::Result { + let signature = reference.signature(); + let signature_bytes: &[u8] = signature.as_bytes(); + let row = match client.query_one(LIVE_CONFLICT_ROLLBACK_STATE_SQL, &[&signature_bytes]).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(LiveFailure::new("conflict_rollback_state_probe")), + }; + return match row.try_get::(0) { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(_) => std::result::Result::Err(LiveFailure::new("conflict_rollback_state_decode")), + }; +} + async fn variant_projection_is_exact( client: &tokio_postgres::Client, reference: &ksp_store_api::RawTransactionReference, diff --git a/crates/ksp-store-postgres-lib/tests/v003_variant_persistence.rs b/crates/ksp-store-postgres-lib/tests/v003_variant_persistence.rs index 42c914a..28f09ec 100644 --- a/crates/ksp-store-postgres-lib/tests/v003_variant_persistence.rs +++ b/crates/ksp-store-postgres-lib/tests/v003_variant_persistence.rs @@ -1,5 +1,5 @@ // file: crates/ksp-store-postgres-lib/tests/v003_variant_persistence.rs -// version: 4 +// version: 5 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -145,3 +145,50 @@ fn v0_3_16_pre_006_promotion_is_inside_acquisition_transaction_and_preserves_pre assert!(promotion.contains("next_transaction_canonical_revision")); return; } + +#[test] +fn v0_3_16_pre_008_conflict_path_never_mutates_canonical_projection_or_selector() { + let source = include_str!("../src/raw_transaction.rs"); + let start = source.find("ExistingTransactionMatch::ActiveConflict(variant_comparison) => {"); + let end = source.find("ExistingTransactionMatch::Purged => {"); + let (start, end) = match (start, end) { + (std::option::Option::Some(start), std::option::Option::Some(end)) if start < end => (start, end), + _ => panic!("pre.008 conflict branch markers are missing"), + }; + let branch = &source[start..end]; + assert!(branch.contains("persist_or_reuse_native_transaction_variant")); + assert!(branch.contains("open_or_update_raw_transaction_conflict")); + assert!(branch.contains("RawTransactionVariantWriteOutcome::QuarantinedConflict")); + assert!(!branch.contains("promote_more_complete_transaction_variant")); + assert!(!branch.contains("UPDATE_CANONICAL_TRANSACTION_PROJECTION_SQL")); + assert!(!branch.contains("UPDATE_TRANSACTION_VARIANT_SELECTOR_SQL")); + return; +} + +#[test] +fn v0_3_16_pre_008_variant_transition_helpers_cannot_commit_independently() { + let source = include_str!("../src/raw_transaction.rs"); + let promotion_start = source.find("async fn promote_more_complete_transaction_variant("); + let promotion_end = source.find("async fn preserve_previous_canonical_variant("); + let conflict_start = source.find("async fn open_or_update_raw_transaction_conflict("); + let conflict_end = source.find("async fn promote_more_complete_transaction_variant("); + let (promotion_start, promotion_end, conflict_start, conflict_end) = match (promotion_start, promotion_end, conflict_start, conflict_end) { + (Some(promotion_start), Some(promotion_end), Some(conflict_start), Some(conflict_end)) + if promotion_start < promotion_end && conflict_start < conflict_end => + { + (promotion_start, promotion_end, conflict_start, conflict_end) + }, + _ => panic!("pre.008 transition helper markers are missing"), + }; + let promotion = &source[promotion_start..promotion_end]; + let conflict = &source[conflict_start..conflict_end]; + for helper in [promotion, conflict] { + assert!(helper.contains("&deadpool_postgres::Transaction<'_>")); + assert!(!helper.contains(".commit().await")); + } + assert!(source.contains("const LOCK_TRANSACTION_SQL: &str = \"SELECT")); + assert!(source.contains("WHERE signature = $1 FOR UPDATE\";")); + assert!(source.contains("canonical_variant_id = $2::TEXT::NUMERIC AND canonical_revision = $3::TEXT::NUMERIC")); + assert!(source.contains("AND revision = $8::TEXT::NUMERIC")); + return; +} diff --git a/crates/ksp-worker-raw-transaction-ingest-lib/unit_tests/runtime.rs b/crates/ksp-worker-raw-transaction-ingest-lib/unit_tests/runtime.rs index 2a49ee7..72182bf 100644 --- a/crates/ksp-worker-raw-transaction-ingest-lib/unit_tests/runtime.rs +++ b/crates/ksp-worker-raw-transaction-ingest-lib/unit_tests/runtime.rs @@ -1,5 +1,5 @@ // file: crates/ksp-worker-raw-transaction-ingest-lib/unit_tests/runtime.rs -// version: 14 +// version: 15 struct ActiveTaskGuard { active: std::sync::Arc, @@ -526,6 +526,50 @@ async fn v0_3_16_pre_007_quarantined_conflict_keeps_worker_running_and_degraded( return; } +#[tokio::test(flavor = "current_thread")] +async fn v0_3_16_pre_008_quarantined_conflict_does_not_block_later_identity() { + let settings = match settings_with_persistence_concurrency(1) { + std::option::Option::Some(value) => value, + std::option::Option::None => return, + }; + let network = settings.network().clone(); + let port = std::sync::Arc::new(RuntimePersistencePort::new(network.clone(), RuntimePortResponse::QuarantinedConflict, true)); + let runtime_port: super::PersistencePort = port.clone(); + let handle = match super::start_foundation_with_port_and_source_spawner( + settings, + tokio::runtime::Handle::current(), + std::option::Option::Some(runtime_port), + move |children, _stop_receiver, admission_sender| { + let _abort_handle = children.spawn(async move { + for signature_byte in [20_u8, 21_u8] { + let ingress = match runtime_ingress(&network, signature_byte) { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(crate::runtime_error("test.source_failed")), + }; + if admission_sender.send(ingress).await.is_err() { + return std::result::Result::Err(crate::runtime_error("test.source_failed")); + } + } + return std::result::Result::Ok(()); + }); + }, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let source = handle.snapshot_source(); + assert!(wait_for_completed(&port, 2).await); + let snapshot = source.current(); + assert_eq!(snapshot.worker_snapshot().state(), ksp_worker_api::WorkerState::Running); + assert_eq!(snapshot.worker_snapshot().health(), ksp_worker_api::WorkerHealth::Degraded); + assert_eq!(snapshot.persisted_total(), 2); + assert_eq!(snapshot.content_conflict_total(), 2); + assert_eq!(snapshot.store_failure_total(), 0); + assert!(handle.request_stop()); + assert!(matches!(handle.wait_terminal().await, std::result::Result::Ok(ksp_worker_api::WorkerState::Stopped))); + return; +} + #[tokio::test(flavor = "current_thread")] async fn pre_007_legacy_store_conflict_error_becomes_terminal_after_private_drain() { let settings = match settings_with_persistence_concurrency(1) { diff --git a/deltas/0.3.16/pre.008.md b/deltas/0.3.16/pre.008.md new file mode 100644 index 0000000..dc01474 --- /dev/null +++ b/deltas/0.3.16/pre.008.md @@ -0,0 +1,236 @@ + + + +# Delta `0.3.16-pre.008` — hardening technique de la vertical slice variantes/conflits + +## Base requise + +```text +0.3.16-pre.007-fix.003 appliqué +workspace.package.version = 0.3.16-pre.7.fix.3 +``` + +Le gate opérateur de `pre.007-fix.003` est entièrement propre : format, audits Rust/Markdown, `cargo check --workspace`, Clippy workspace/all-targets/all-features, Store API, Store façade, PostgreSQL, Job Backfill et l'intégralité des suites ciblées du Worker passent. `pre.007` est donc fermé avant ouverture de cette tranche. + +## Objectif + +`pre.008` est la tranche de hardening technique finale de la vertical slice `0.3.16` : + +```text +concurrence même identité +rollback transactionnel promotion/conflit +cohérence projection V001 / selector V003 +conservation du canonique en conflit +régressions Job Backfill / Worker +preuves live PostgreSQL opt-in actualisées +gate workspace +``` + +Aucune nouvelle capacité fonctionnelle, résolution opérateur, retry Store, reconnexion Transport ou Store Desk n'est introduite. + +## Version + +```text +workspace.package.version = 0.3.16-pre.8 +``` + +## Hardening PostgreSQL statique + +Les canaris V003 verrouillent désormais explicitement deux invariants supplémentaires : + +1. la branche `ActiveConflict` persiste/réutilise la variante entrante et ouvre/complète le conflict case, mais n'appelle jamais la promotion canonique et ne touche ni `UPDATE_CANONICAL_TRANSACTION_PROJECTION_SQL` ni `UPDATE_TRANSACTION_VARIANT_SELECTOR_SQL` ; +2. les helpers de promotion et de conflict case reçoivent la transaction PostgreSQL de l'acquisition et ne peuvent jamais effectuer leur propre `COMMIT`. + +Les gardes existantes restent exigées : + +```text +identity row -> FOR UPDATE +selector promotion -> expected canonical_variant_id + expected canonical_revision +conflict update -> expected revision +``` + +Ainsi, selector, projection V001, ledger V003, conflict case, observation et mapping restent bornés par la transaction d'acquisition externe. + +## Preuve live PostgreSQL actualisée + +Le test opt-in `postgres_raw_transaction_live.rs` est réaligné sur la sémantique `pre.007`. + +Le scénario de divergence concurrente d'une même identité attend désormais : + +```text +1 x InsertedCanonical +1 x QuarantinedConflict +2 variantes durables +2 observations durables +2 mappings observation -> variante +1 selector canonique +1 conflict case Open revision 1 +projection V001 == variante désignée par le selector +``` + +L'ordre d'arrivée n'est pas supposé : l'une ou l'autre acquisition peut devenir le premier canonique, mais la seconde doit être quarantinée sans écraser ce canonique. + +### Rollback après ouverture réelle d'un conflit + +Un nouveau scénario live force une erreur après les écritures de divergence mais avant le commit : + +```text +canonique existant +-> incoming divergent +-> variante entrante créée +-> conflict case créé +-> collision volontaire d'observation avec une autre identité +-> erreur avant COMMIT +-> rollback implicite de la transaction +``` + +Après l'échec, la preuve exige exactement l'état antérieur : + +```text +1 seule variante pour l'identité +1 selector +0 conflict case +1 observation de seed +1 mapping de seed +projection V001 toujours cohérente avec le selector +``` + +Cela couvre le risque qu'un conflict case ou une variante orpheline survive à un échec tardif de l'acquisition. + +Le test live reste `#[ignore]`, lit uniquement un URI PostgreSQL dédié depuis stdin, refuse un schéma KSP préexistant et ne devient pas un gate automatique sans ressource opérateur explicite. + +## Régression Job Backfill + +Le Job Backfill ajoute un canari sur une réobservation déjà idempotente d'un conflit durable : + +```text +entity = AlreadyPresent +observation = AlreadyPresent +variant = QuarantinedConflict +``` + +La projection doit rester : + +```text +BackfillEntityPersistence::Conflict +BackfillObservationPersistence::AlreadyPresent +``` + +Le conflit durable n'est donc jamais aplati en simple `AlreadyPresent`, même lorsque l'observation est elle-même idempotente. + +## Régression Worker + +Le Worker ajoute une preuve runtime avec deux identités successives alors que le Store retourne `QuarantinedConflict` : + +```text +première identité -> QuarantinedConflict +seconde identité -> QuarantinedConflict +``` + +Les deux acquisitions doivent atteindre le Store. Après les deux résultats : + +```text +state = Running +health = Degraded +persisted_total = 2 +content_conflict_total = 2 +store_failure_total = 0 +``` + +Le Stop explicite doit ensuite terminer en `Stopped`. Cette preuve verrouille directement l'invariant « un conflit durable n'empêche pas la progression d'une autre identité ». + +## Recalibrage du lifecycle de fermeture + +Le plan 038 est ajusté pour respecter les règles `VER-LIFECYCLE` actuelles, qui sont plus strictes que le découpage historique `pre.008 -> rel.001` : + +```text +pre.008 = hardening / gate technique +pre.009 = réconciliation documentaire finale +pre.010 = préparation minimale de publication +rel.001 = mécanique de publication stable +``` + +`pre.008` ne modifie donc ni `CHANGELOG.md`, ni `ROADMAP.md`, ni le prompt de démarrage suivant, et ne fait pas la réconciliation README/USAGE finale. + +## Fichiers modifiés + +```text +Cargo.toml +crates/ksp-job-backfill-lib/unit_tests/persistence.rs +crates/ksp-store-postgres-lib/tests/hardening_completeness.rs +crates/ksp-store-postgres-lib/tests/postgres_raw_transaction_live.rs +crates/ksp-store-postgres-lib/tests/v003_variant_persistence.rs +crates/ksp-worker-raw-transaction-ingest-lib/unit_tests/runtime.rs +docs/plans/038-V0_3_16_RAW_RESILIENCE_CONFLICT_PLAN.md +``` + +## Fichier ajouté + +```text +deltas/0.3.16/pre.008.md +``` + +## Fichiers supprimés + +```text +aucun +``` + +## Validations exécutées lors de la génération + +```bash +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 +``` + +Le toolchain Rust n'est pas installé dans l'environnement de génération. `cargo fmt`, `cargo check`, Clippy et les tests Cargo ne sont donc pas déclarés exécutés ici. + +La preuve PostgreSQL live reste volontairement opt-in et n'est pas déclarée exécutée sans URI isolé fourni par l'opérateur. + +## Gate opérateur demandé + +```bash +cargo fmt --all +cargo fmt --all -- --check + +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 + +cargo check --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings + +cargo test -p ksp-store-api --all-targets --all-features +cargo test -p ksp-store-lib --all-targets --all-features +cargo test -p ksp-store-postgres-lib --all-targets --all-features +cargo test -p ksp-job-backfill-lib --all-targets --all-features +cargo test -p ksp-worker-raw-transaction-ingest-lib --all-targets --all-features +``` + +Le live PostgreSQL peut être exécuté séparément sur une base dédiée vide : + +```bash +cargo test -p ksp-store-postgres-lib --test postgres_raw_transaction_live -- --ignored --nocapture +``` + +Il ne doit jamais être lancé contre une base KSP contenant des données utiles. + +## Décisions prises + +- aucune modification runtime n'est nécessaire pour fermer le hardening observé ; +- le conflit durable concurrent est un succès quarantiné, pas une erreur backend ; +- les deux observations concurrentes divergentes doivent être conservées ; +- un échec tardif avant commit doit annuler variante et conflict case nouvellement créés ; +- le canonique ne change jamais dans la branche de conflit ; +- Backfill conserve explicitement la distinction conflit / observation idempotente ; +- le Worker doit continuer à traiter les identités suivantes après une quarantaine ; +- le lifecycle final est séparé en `pre.008`, `pre.009`, `pre.010`, puis `rel.001` conformément aux règles actuelles. + +## Questions ouvertes + +Aucune question fonctionnelle bloquante pour `0.3.16`. + +Le test PostgreSQL live reste conditionné à la disponibilité d'un PostgreSQL isolé ; son absence n'autorise pas à prétendre qu'il a été exécuté. + +## Suite + +Après gate technique propre : `0.3.16-pre.009` — réconciliation documentaire finale de `0.3.16`, sans nouveau comportement runtime. diff --git a/docs/plans/038-V0_3_16_RAW_RESILIENCE_CONFLICT_PLAN.md b/docs/plans/038-V0_3_16_RAW_RESILIENCE_CONFLICT_PLAN.md index 62e345e..3c4a91b 100644 --- a/docs/plans/038-V0_3_16_RAW_RESILIENCE_CONFLICT_PLAN.md +++ b/docs/plans/038-V0_3_16_RAW_RESILIENCE_CONFLICT_PLAN.md @@ -1,5 +1,5 @@ - + # Plan `0.3.16` -> `0.3.18` — résilience RAW, variantes, conflits et récupération @@ -990,7 +990,15 @@ Conflit durable minimal + intégration Worker : variante conservée, case ouvert #### `0.3.16-pre.008` -Hardening ciblé de la vertical slice : concurrence, rollback transactionnel, régression Job Backfill/Store, gates workspace, validation documentaire et préparation de `0.3.16-rel.001`. +Hardening technique ciblé de la vertical slice : concurrence, rollback transactionnel, régressions Store/Job Backfill/Worker, preuves PostgreSQL live opt-in actualisées et gate workspace. Cette tranche ne mélange ni réconciliation documentaire durable ni préparation de publication. + +#### `0.3.16-pre.009` + +Réconciliation documentaire finale de `0.3.16` : plan, validation, README/USAGE concernés et cohérence des contrats durables. Aucun nouveau comportement runtime ni nouveau smoke n'est introduit. + +#### `0.3.16-pre.010` + +Préparation de publication minimale : `CHANGELOG.md`, `ROADMAP.md`, prompt de démarrage de `0.3.17`, version et delta obligatoires uniquement. #### `0.3.16-rel.001`