From 44315b5875ec3c1c600318f0f7b4f801add3852c Mon Sep 17 00:00:00 2001 From: dsecurity49 Date: Thu, 27 Aug 2026 19:44:56 +0530 Subject: [PATCH] chore: prepare v0.6.2 release --- .github/ISSUE_TEMPLATE/database-feedback.yml | 2 +- CHANGELOG.md | 13 + Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 4 +- docs/BENCHMARKS.md | 38 +++ docs/CONTRACT.md | 2 +- docs/GITHUB_ACTIONS.md | 8 +- scripts/test-action-contract | 2 +- src/ast/visitor.rs | 2 +- src/ast/visitor_tests.rs | 11 + tests/invariant_sequences.rs | 325 +++++++++++++++++++ tests/performance_scenarios.rs | 317 ++++++++++++++++++ 13 files changed, 716 insertions(+), 12 deletions(-) create mode 100644 docs/BENCHMARKS.md create mode 100644 tests/invariant_sequences.rs create mode 100644 tests/performance_scenarios.rs diff --git a/.github/ISSUE_TEMPLATE/database-feedback.yml b/.github/ISSUE_TEMPLATE/database-feedback.yml index f52b6c7..54c4ab7 100644 --- a/.github/ISSUE_TEMPLATE/database-feedback.yml +++ b/.github/ISSUE_TEMPLATE/database-feedback.yml @@ -13,7 +13,7 @@ body: attributes: label: safe-migrate version description: Paste the output of `safe-migrate --version`. - placeholder: safe-migrate 0.6.1 + placeholder: safe-migrate 0.6.2 validations: required: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 98d7cf2..5c19c59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ commits and pull requests. Published binaries, checksums, and generated release notes are available on the [GitHub Releases page](https://github.com/dsecurity49/safe-migrate/releases). +## v0.6.2 — 2026-08-27 + +- Added reproducible, ignored performance scenarios for large synchronized + baselines, ordered chains, transaction and savepoint rollback, compound + statements, dependency graphs, reports, and the complete protected-cache + round trip, with a recorded local baseline and no timing-sensitive CI gate. +- Added test-only cache and state invariant validation for modeled identities, + cache relationships, dependency edges, constraints, pending validation, and + transaction-frame consistency across conflicts and rollbacks. +- Hardened `ALTER DATABASE ... OWNER TO` extraction so an incomplete typed AST + is rejected instead of reaching an unchecked accessor, while preserving the + exact fact produced for valid SQL. + ## v0.6.1 — 2026-08-26 - Upgraded the exactly pinned Squawk parser stack from 2.62.0 to 2.63.0 and diff --git a/Cargo.lock b/Cargo.lock index 7b49897..968af07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1020,7 +1020,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "safe-migrate" -version = "0.6.1" +version = "0.6.2" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 6293c03..1c21858 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "safe-migrate" -version = "0.6.1" +version = "0.6.2" edition = "2024" rust-version = "1.94" description = "Sync PostgreSQL metadata, then lint migrations offline" diff --git a/README.md b/README.md index f3f3c6f..6870535 100644 --- a/README.md +++ b/README.md @@ -310,7 +310,7 @@ and GitHub cache contents are not signed. Store a 64-character hexadecimal key as `SAFE_MIGRATE_CACHE_KEY` and pass it to both workflows. ```yaml -- uses: dsecurity49/safe-migrate@v0.6.1 +- uses: dsecurity49/safe-migrate@v0.6.2 env: DATABASE_URL: ${{ secrets.SAFE_MIGRATE_DATABASE_URL }} SAFE_MIGRATE_CACHE_KEY: ${{ secrets.SAFE_MIGRATE_CACHE_KEY }} @@ -329,7 +329,7 @@ Replace `public` with the schemas that contain your migrations, or omit Add this after checkout in the pull-request workflow: ```yaml -- uses: dsecurity49/safe-migrate@v0.6.1 +- uses: dsecurity49/safe-migrate@v0.6.2 env: SAFE_MIGRATE_CACHE_KEY: ${{ secrets.SAFE_MIGRATE_CACHE_KEY }} with: diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md new file mode 100644 index 0000000..c2ecd0a --- /dev/null +++ b/docs/BENCHMARKS.md @@ -0,0 +1,38 @@ +# Local benchmark baseline + +This document records reproducible, non-CI performance scenarios. The values +are comparison points for later `0.6.x` work, not performance guarantees. +Run them with: + +```sh +cargo test --locked --test performance_scenarios -- --ignored --nocapture +``` + +The scenarios validate final state as well as timing, so an apparent speedup +that breaks transaction cleanup is not a valid comparison. + +## Initial `v0.6.2` baseline + +Captured on 2026-08-26 from base commit `ac8b5ad`, with local uncommitted +`v0.6.2` hardening changes, using Rust 1.98.0 on an aarch64 Android Linux +environment. Timings are wall-clock milliseconds from a debug test build and +will vary with device load. + +| Scenario | Statements | Elapsed | +| --- | ---: | ---: | +| ordered thousand-statement chain | 1,000 | 11,595 ms | +| large synchronized-baseline hydration | 1,000 relations | 99 ms | +| cache encode/compress/encrypt/decrypt/decompress/decode | 1,000 relations | 99 ms | +| long transaction rollback | 503 | 4,952 ms | +| repeated savepoint rollback | 752 | 1,586 ms | +| failed multi-action statement rollback | 3 | 14 ms | +| rename and cascade dependency graph | 304 | 3,323 ms | +| location-rich reports with many findings | 250 | 2,440 ms | + +The scenarios cover ordered-chain analysis, baseline hydration, cache +processing, transaction undo, compound-statement atomicity, savepoint cleanup, +rename/cascade graph cleanup, and location-rich report generation. They +intentionally avoid timing thresholds in CI. Allocation, peak-memory, +checkpoint-capture, and isolated dependency-query measurements require a +profiler or allocator instrumentation and are deliberately not inferred from +these wall-clock samples. diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index c29c90b..55fa26d 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -1,6 +1,6 @@ # CLI and Report Contract -This document defines safe-migrate v0.6.1's CLI, report, cache, and GitHub +This document defines safe-migrate v0.6.2's CLI, report, cache, and GitHub Action behavior. If you are learning safe-migrate, start with the [README](../README.md). This diff --git a/docs/GITHUB_ACTIONS.md b/docs/GITHUB_ACTIONS.md index 3243793..775c1a6 100644 --- a/docs/GITHUB_ACTIONS.md +++ b/docs/GITHUB_ACTIONS.md @@ -54,7 +54,7 @@ jobs: with: persist-credentials: false - - uses: dsecurity49/safe-migrate@v0.6.1 + - uses: dsecurity49/safe-migrate@v0.6.2 env: SAFE_MIGRATE_CACHE_KEY: ${{ secrets.SAFE_MIGRATE_CACHE_KEY }} with: @@ -83,7 +83,7 @@ explicitly. In a trusted branch job, pass the checked-out file directly: ```yaml - - uses: dsecurity49/safe-migrate@v0.6.1 + - uses: dsecurity49/safe-migrate@v0.6.2 with: path: migrations config: safe-migrate.toml @@ -107,7 +107,7 @@ separately: sparse-checkout-cone-mode: false persist-credentials: false - - uses: dsecurity49/safe-migrate@v0.6.1 + - uses: dsecurity49/safe-migrate@v0.6.2 with: path: migrations config: .safe-migrate-base/safe-migrate.toml @@ -153,7 +153,7 @@ jobs: with: persist-credentials: false - - uses: dsecurity49/safe-migrate@v0.6.1 + - uses: dsecurity49/safe-migrate@v0.6.2 env: DATABASE_URL: ${{ secrets.SAFE_MIGRATE_DATABASE_URL }} SAFE_MIGRATE_CACHE_KEY: ${{ secrets.SAFE_MIGRATE_CACHE_KEY }} diff --git a/scripts/test-action-contract b/scripts/test-action-contract index 1696059..d128bfd 100755 --- a/scripts/test-action-contract +++ b/scripts/test-action-contract @@ -9,7 +9,7 @@ baseline="$repo_root/scripts/action-baseline" manifest="$repo_root/action.yml" workflow="$repo_root/.github/workflows/ci.yml" -test "$(/bin/sh "$resolver" v0.6.1 "$repo_root/Cargo.toml")" = v0.6.1 +test "$(/bin/sh "$resolver" v0.6.2 "$repo_root/Cargo.toml")" = v0.6.2 test "$(/bin/sh "$resolver" 0123456789abcdef0123456789abcdef01234567 "$repo_root/Cargo.toml")" = source if /bin/sh "$resolver" main "$repo_root/Cargo.toml" >/dev/null 2>&1; then diff --git a/src/ast/visitor.rs b/src/ast/visitor.rs index 5423f1a..19723e9 100644 --- a/src/ast/visitor.rs +++ b/src/ast/visitor.rs @@ -3249,7 +3249,7 @@ impl AstVisitor { } AlterDatabaseAction::OwnerTo(ot) => { crate::analysis::facts::AlterDatabaseAction::OwnerChange(Self::extract_role( - &ot.role_ref().unwrap(), + &ot.role_ref()?, )) } AlterDatabaseAction::SetTablespace(st) => { diff --git a/src/ast/visitor_tests.rs b/src/ast/visitor_tests.rs index f444539..c3c37fc 100644 --- a/src/ast/visitor_tests.rs +++ b/src/ast/visitor_tests.rs @@ -998,6 +998,17 @@ mod tests { database.action, AlterDatabaseAction::Rename { to } if to == "NewDb" )); + + let Some(StatementFact::AlterDatabase(database)) = + parse_and_extract_statement("ALTER DATABASE app OWNER TO AppOwner;") + else { + panic!("expected alter database owner fact"); + }; + assert!(matches!( + database.action, + AlterDatabaseAction::OwnerChange(crate::analysis::facts::RoleFact::Named { name, .. }) + if name == "appowner" + )); } #[test] diff --git a/tests/invariant_sequences.rs b/tests/invariant_sequences.rs new file mode 100644 index 0000000..62d98c4 --- /dev/null +++ b/tests/invariant_sequences.rs @@ -0,0 +1,325 @@ +mod common; + +mod invariant_sequences { + use crate::common::{cache_with_table, object_id, setup_engine, setup_state}; + use safe_migrate::analysis::graph::DependencyKind; + use safe_migrate::analysis::state::AnalysisState; + use safe_migrate::analysis::transaction::TransactionFrameKind; + use safe_migrate::db::cache::DbCache; + use safe_migrate::model::function::FunctionOverlay; + use safe_migrate::model::relation::RelationOverlay; + use safe_migrate::model::replication::{PublicationOverlay, SubscriptionOverlay}; + use safe_migrate::model::role::RoleOverlay; + use safe_migrate::model::schema::SchemaOverlay; + use safe_migrate::model::sequence::SequenceOverlay; + use safe_migrate::model::trigger::TriggerOverlay; + use safe_migrate::model::types::TypeOverlay; + use std::collections::HashSet; + + fn assert_cache_invariants(cache: &DbCache) { + for (id, relation) in &cache.relations { + assert_eq!( + id, &relation.id, + "cached relation map key disagrees with state" + ); + } + for (id, ty) in &cache.types { + assert_eq!(id, &ty.id, "cached type map key disagrees with state"); + } + for (id, function) in &cache.functions { + assert_eq!( + id, &function.id, + "cached function map key disagrees with state" + ); + } + for (id, sequence) in &cache.sequences { + assert_eq!( + id, &sequence.id, + "cached sequence map key disagrees with state" + ); + if let Some((table_id, _)) = &sequence.owned_by { + assert!( + cache.relations.contains_key(table_id), + "cached owned sequence must reference a cached relation" + ); + } + } + for (id, role) in &cache.roles { + assert_eq!(id, &role.id, "cached role map key disagrees with state"); + } + for (name, schema) in &cache.schemas { + assert_eq!( + name, &schema.name, + "cached schema map key disagrees with state" + ); + } + for (name, publication) in &cache.publications { + assert_eq!( + name, &publication.name, + "cached publication map key disagrees with state" + ); + } + for (name, subscription) in &cache.subscriptions { + assert_eq!( + name, &subscription.name, + "cached subscription map key disagrees with state" + ); + } + + let mut constraint_keys = HashSet::new(); + for constraint in &cache.constraints { + assert!( + cache.relations.contains_key(&constraint.table_id), + "cached constraint must reference a cached relation" + ); + assert!( + constraint_keys.insert((constraint.table_id.clone(), constraint.name.clone())), + "cached constraints must have unique table/name identities" + ); + } + for index in &cache.indexes { + assert!( + cache.relations.contains_key(&index.table_id), + "cached index must reference a cached relation" + ); + } + for trigger in &cache.triggers { + assert!( + cache.relations.contains_key(&trigger.table_id), + "cached trigger must reference a cached relation" + ); + } + for foreign_key in &cache.foreign_keys { + assert!( + cache.relations.contains_key(&foreign_key.from_table) + && cache.relations.contains_key(&foreign_key.to_table), + "cached foreign key must reference cached relations" + ); + } + } + + fn assert_state_invariants(state: &AnalysisState) { + let local = &state.local; + + for (name, schema) in &local.schemas { + if let SchemaOverlay::Present(schema) = schema { + assert_eq!(name, &schema.name, "schema map key disagrees with state"); + } + } + for (id, relation) in &local.relations { + if let RelationOverlay::Present(relation) = relation { + assert_eq!(id, &relation.id, "relation map key disagrees with state"); + } + } + for (id, ty) in &local.types { + if let TypeOverlay::Present(ty) = ty { + assert_eq!(id, &ty.id, "type map key disagrees with state"); + } + } + for (id, function) in &local.functions { + if let FunctionOverlay::Present(function) = function { + assert_eq!(id, &function.id, "function map key disagrees with state"); + } + } + for (id, sequence) in &local.sequences { + if let SequenceOverlay::Present(sequence) = sequence { + assert_eq!(id, &sequence.id, "sequence map key disagrees with state"); + } + } + for (id, role) in &local.roles { + if let RoleOverlay::Present(role) = role { + assert_eq!(id, &role.id, "role map key disagrees with state"); + } + } + for (name, publication) in &local.publications { + if let PublicationOverlay::Present(publication) = publication { + assert_eq!( + name, &publication.name, + "publication map key disagrees with state" + ); + } + } + for (name, subscription) in &local.subscriptions { + if let SubscriptionOverlay::Present(subscription) = subscription { + assert_eq!( + name, &subscription.name, + "subscription map key disagrees with state" + ); + } + } + for (id, trigger) in &local.triggers { + if let TriggerOverlay::Present(trigger) = trigger { + assert_eq!(id, &trigger.id, "trigger map key disagrees with state"); + assert!( + !matches!( + local.relations.get(&trigger.table_id), + Some(RelationOverlay::Dropped) + ), + "present trigger belongs to a dropped relation" + ); + } + } + for ((table_id, name), constraint) in &local.constraints { + assert_eq!( + table_id, &constraint.table_id, + "constraint table key disagrees with state" + ); + assert_eq!( + name, &constraint.name, + "constraint name key disagrees with state" + ); + assert!( + !matches!( + local.relations.get(table_id), + Some(RelationOverlay::Dropped) + ), + "constraint belongs to a dropped relation" + ); + } + for key in &local.pending_validation { + let constraint = local + .constraints + .get(key) + .expect("pending validation must reference a known constraint"); + assert!( + !constraint.validated, + "validated constraint cannot be pending" + ); + } + + for edge in &local.graph.edges { + match &edge.kind { + DependencyKind::ForeignKey { + constraint_name: Some(name), + .. + } => assert!( + local + .constraints + .contains_key(&(edge.dependent.clone(), name.clone())), + "foreign-key edge must have a matching constraint" + ), + DependencyKind::ViewDependency { .. } => { + assert!(matches!( + local.relations.get(&edge.dependent), + Some(RelationOverlay::Present(relation)) + if matches!( + relation.kind, + safe_migrate::model::relation::RelationKind::View + | safe_migrate::model::relation::RelationKind::MaterializedView + ) + )); + assert!(!matches!( + local.relations.get(&edge.referenced), + Some(RelationOverlay::Dropped) + )); + } + DependencyKind::SequenceOwnedBy { column } => assert!(matches!( + local.sequences.get(&edge.dependent), + Some(SequenceOverlay::Present(sequence)) + if sequence.owned_by == Some((edge.referenced.clone(), column.clone())) + )), + DependencyKind::TriggerOnTable { trigger_id, .. } => assert!(matches!( + local.triggers.get(trigger_id), + Some(TriggerOverlay::Present(trigger)) if trigger.table_id == edge.referenced + )), + DependencyKind::PublicationIncludes { publication_name } => assert!(matches!( + local.publications.get(publication_name), + Some(PublicationOverlay::Present(publication)) if publication.name == *publication_name + )), + DependencyKind::IndexOnRelation { .. } + | DependencyKind::RenameTo + | DependencyKind::PartitionOf + | DependencyKind::ColumnGeneratedFrom { .. } + | DependencyKind::ForeignKey { + constraint_name: None, + .. + } => {} + } + } + + if local.transactions.is_empty() { + assert!( + !local.transaction_aborted, + "an aborted transaction must retain its root frame" + ); + } else { + assert!(matches!( + local.transactions.first().map(|frame| &frame.kind), + Some(TransactionFrameKind::Root) + )); + assert!( + local + .transactions + .iter() + .skip(1) + .all(|frame| matches!(frame.kind, TransactionFrameKind::Savepoint { .. })), + "only the first transaction frame may be the root" + ); + } + } + + fn analyze_and_validate(state: &mut AnalysisState, sql: &str) { + let findings = setup_engine() + .analyze(sql, state) + .expect("scenario statement should analyze"); + assert_state_invariants(state); + if sql.contains("missing_column") { + assert!( + findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict") + ); + } + } + + #[test] + fn state_invariants_hold_across_ddl_conflict_and_savepoint_rollback() { + let mut state = setup_state(); + + analyze_and_validate( + &mut state, + "CREATE TABLE accounts (id bigint PRIMARY KEY, email text);", + ); + analyze_and_validate( + &mut state, + "CREATE TABLE orders (id bigint PRIMARY KEY, account_id bigint);", + ); + analyze_and_validate( + &mut state, + "ALTER TABLE orders ADD CONSTRAINT orders_account_fk FOREIGN KEY (account_id) REFERENCES accounts(id) NOT VALID;", + ); + analyze_and_validate(&mut state, "BEGIN;"); + analyze_and_validate(&mut state, "ALTER TABLE accounts RENAME TO customers;"); + analyze_and_validate(&mut state, "SAVEPOINT before_failure;"); + analyze_and_validate( + &mut state, + "ALTER TABLE customers DROP COLUMN missing_column;", + ); + analyze_and_validate(&mut state, "ROLLBACK TO SAVEPOINT before_failure;"); + analyze_and_validate(&mut state, "COMMIT;"); + + assert!(state.relation_is_present(&object_id("public", "customers"))); + assert!(!state.relation_is_present(&object_id("public", "accounts"))); + } + + #[test] + fn cache_hydration_preserves_baseline_identity_and_state_invariants() { + let table_id = object_id("app", "cached_accounts"); + let cache = cache_with_table("app", "cached_accounts", Some(42)); + assert_cache_invariants(&cache); + let state = AnalysisState::with_baseline(cache, true); + + assert_state_invariants(&state); + assert!(state.baseline_available); + assert!(state.baseline_relations.contains(&table_id)); + assert!(state.relation_is_present(&table_id)); + assert!(matches!( + state.local.relations.get(&table_id), + Some(RelationOverlay::Present(relation)) if relation.estimated_rows == Some(42) + )); + assert!(matches!( + state.local.schemas.get("app"), + Some(SchemaOverlay::Present(schema)) if schema.name == "app" + )); + } +} diff --git a/tests/performance_scenarios.rs b/tests/performance_scenarios.rs new file mode 100644 index 0000000..71ebd0c --- /dev/null +++ b/tests/performance_scenarios.rs @@ -0,0 +1,317 @@ +mod common; + +mod performance_scenarios { + use crate::common::{object_id, setup_engine, setup_state}; + use safe_migrate::db::cache::{DbCache, DbCacheVersioned}; + use safe_migrate::db::cache_file::{CACHE_KEY_ENV, protect_cache_bytes, unprotect_cache_bytes}; + use safe_migrate::model::relation::{Persistence, RelationKind, RelationState}; + use std::io::Cursor; + use std::time::Instant; + + const LARGE_BASELINE_RELATIONS: usize = 1_000; + const LONG_CHAIN_STATEMENTS: usize = 1_000; + const ROLLBACK_STATEMENTS: usize = 500; + const SAVEPOINT_ITERATIONS: usize = 250; + const GRAPH_OBJECTS: usize = 100; + const REPORT_FINDINGS: usize = 250; + + fn report_elapsed(scenario: &str, statements: usize, elapsed: std::time::Duration) { + eprintln!( + "scenario={scenario} statements={statements} elapsed_ms={}", + elapsed.as_millis() + ); + } + + fn large_baseline() -> DbCache { + let mut cache = DbCache::new(); + cache.search_path = vec!["public".to_string()]; + for index in 0..LARGE_BASELINE_RELATIONS { + let id = object_id("public", &format!("perf_baseline_{index}")); + cache.insert_baseline( + id.clone(), + RelationState::new( + id, + object_id("public", "postgres"), + 0, + Some(1_000), + RelationKind::Table, + Persistence::Permanent, + 0, + ), + ); + } + cache + } + + #[test] + #[ignore = "manual performance scenario; run with --ignored --nocapture"] + fn ordered_thousand_statement_chain() { + let engine = setup_engine(); + let mut state = setup_state(); + let files = (0..LONG_CHAIN_STATEMENTS) + .map(|index| { + ( + format!("V{index:04}__create.sql"), + format!("CREATE TABLE IF NOT EXISTS perf_chain_{index} (id bigint NOT NULL);"), + ) + }) + .collect::>(); + + let started = Instant::now(); + let findings = engine + .analyze_chain(&files, &mut state) + .expect("long ordered chain should analyze"); + let elapsed = started.elapsed(); + + assert!(findings.is_empty(), "unexpected findings: {findings:?}"); + assert!(state.relation_is_present(&object_id("public", "perf_chain_999"))); + report_elapsed( + "ordered_thousand_statement_chain", + LONG_CHAIN_STATEMENTS, + elapsed, + ); + } + + #[test] + #[ignore = "manual performance scenario; run with --ignored --nocapture"] + fn large_synchronized_baseline_hydration() { + let started = Instant::now(); + let state = safe_migrate::AnalysisState::with_baseline(large_baseline(), true); + let elapsed = started.elapsed(); + + assert!(state.baseline_available); + assert_eq!(state.baseline_relations.len(), LARGE_BASELINE_RELATIONS); + assert!(state.relation_is_present(&object_id("public", "perf_baseline_999"))); + report_elapsed( + "large_synchronized_baseline_hydration", + LARGE_BASELINE_RELATIONS, + elapsed, + ); + } + + #[test] + #[ignore = "manual performance scenario; run with --ignored --nocapture"] + fn cache_encode_compress_encrypt_round_trip() { + let cache = large_baseline(); + let started = Instant::now(); + let config = bincode::config::standard().with_variable_int_encoding(); + let payload = bincode::serde::encode_to_vec(DbCacheVersioned::V6(Box::new(cache)), config) + .expect("cache should encode"); + let compressed = zstd::stream::encode_all(Cursor::new(payload), 3) + .expect("cache payload should compress"); + unsafe { + std::env::set_var( + CACHE_KEY_ENV, + "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + ); + } + let encrypted = protect_cache_bytes(compressed, true).expect("cache should encrypt"); + let compressed = + unprotect_cache_bytes(encrypted.clone(), true).expect("cache should decrypt"); + unsafe { + std::env::remove_var(CACHE_KEY_ENV); + } + let payload = zstd::stream::decode_all(Cursor::new(compressed)) + .expect("cache payload should decompress"); + let decoded: DbCacheVersioned = bincode::serde::decode_from_slice(&payload, config) + .expect("cache payload should decode") + .0; + let cache = decoded + .into_cache() + .expect("cache version should be current"); + let elapsed = started.elapsed(); + + assert_eq!(cache.relations.len(), LARGE_BASELINE_RELATIONS); + eprintln!( + "scenario=cache_encode_compress_encrypt_round_trip relations={LARGE_BASELINE_RELATIONS} encrypted_bytes={} elapsed_ms={}", + encrypted.len(), + elapsed.as_millis() + ); + } + + #[test] + #[ignore = "manual performance scenario; run with --ignored --nocapture"] + fn rename_and_cascade_dependency_graph() { + let engine = setup_engine(); + let mut state = setup_state(); + let mut sql = String::from( + "CREATE TABLE IF NOT EXISTS perf_graph_parent (id bigint PRIMARY KEY);\nCREATE VIEW perf_graph_parent_view AS SELECT id FROM perf_graph_parent;\n", + ); + for index in 0..GRAPH_OBJECTS { + sql.push_str(&format!( + "CREATE TABLE IF NOT EXISTS perf_graph_child_{index} (id bigint PRIMARY KEY, parent_id bigint REFERENCES perf_graph_parent(id));\nCREATE INDEX IF NOT EXISTS perf_graph_child_{index}_parent_idx ON perf_graph_child_{index}(parent_id);\nCREATE VIEW perf_graph_view_{index} AS SELECT parent_id FROM perf_graph_child_{index};\n" + )); + } + sql.push_str( + "ALTER TABLE perf_graph_parent RENAME TO perf_graph_renamed;\nDROP TABLE perf_graph_renamed CASCADE;", + ); + + let started = Instant::now(); + let findings = engine + .analyze(&sql, &mut state) + .expect("graph scenario should analyze"); + let elapsed = started.elapsed(); + + assert!( + findings + .iter() + .all(|finding| finding.rule_id != "chain-conflict"), + "graph workload must complete without a state conflict: {findings:?}" + ); + assert!(!state.relation_is_present(&object_id("public", "perf_graph_renamed"))); + assert!(!state.relation_is_present(&object_id("public", "perf_graph_parent_view"))); + assert!(state.relation_is_present(&object_id("public", "perf_graph_child_0"))); + assert!(state.relation_is_present(&object_id("public", "perf_graph_view_99"))); + report_elapsed( + "rename_and_cascade_dependency_graph", + GRAPH_OBJECTS * 3 + 4, + elapsed, + ); + } + + #[test] + #[ignore = "manual performance scenario; run with --ignored --nocapture"] + fn location_rich_reports_with_many_findings() { + let engine = setup_engine(); + let mut state = setup_state(); + let sql = (0..REPORT_FINDINGS) + .map(|index| format!("CREATE TABLE perf_report_{index} (id bigint NOT NULL);")) + .collect::>() + .join("\n"); + + let started = Instant::now(); + let findings = engine + .analyze_with_locations("performance.sql".to_string(), sql, &mut state) + .expect("report scenario should analyze"); + let json = + safe_migrate::Reporter::json_report_with_locations(&findings, &state.local.confidence); + let markdown = safe_migrate::Reporter::markdown_report(&findings, &state.local.confidence); + let elapsed = started.elapsed(); + + assert_eq!(findings.len(), REPORT_FINDINGS); + assert_eq!(json["summary"]["total"], REPORT_FINDINGS); + assert!(markdown.contains("perf_report_249")); + report_elapsed( + "location_rich_reports_with_many_findings", + REPORT_FINDINGS, + elapsed, + ); + } + + #[test] + #[ignore = "manual performance scenario; run with --ignored --nocapture"] + fn long_transaction_rollback() { + let engine = setup_engine(); + let mut state = setup_state(); + let mut sql = String::from("BEGIN;\n"); + for index in 0..ROLLBACK_STATEMENTS { + sql.push_str(&format!( + "CREATE TABLE perf_undo_{index} (id bigint NOT NULL);\n" + )); + } + sql.push_str("ALTER TABLE perf_undo_0 DROP COLUMN missing_column;\nCOMMIT;"); + + let started = Instant::now(); + let findings = engine + .analyze(&sql, &mut state) + .expect("rollback scenario should analyze"); + let elapsed = started.elapsed(); + + assert!( + findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict"), + "missing rollback conflict: {findings:?}" + ); + assert!( + !state.relation_is_present(&object_id("public", "perf_undo_0")), + "rollback must remove transaction-local tables" + ); + report_elapsed( + "long_transaction_rollback", + ROLLBACK_STATEMENTS + 3, + elapsed, + ); + } + + #[test] + #[ignore = "manual performance scenario; run with --ignored --nocapture"] + fn multi_action_failure_restores_the_statement_state() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "CREATE TABLE perf_multi_action (id bigint NOT NULL);", + &mut state, + ) + .expect("baseline table should analyze"); + + let started = Instant::now(); + let findings = engine + .analyze( + "BEGIN; + ALTER TABLE perf_multi_action + ADD COLUMN first_value integer, + ADD COLUMN second_value integer, + DROP COLUMN missing_column; + COMMIT;", + &mut state, + ) + .expect("multi-action rollback scenario should analyze"); + let elapsed = started.elapsed(); + + assert!( + findings + .iter() + .any(|finding| finding.rule_id == "chain-conflict") + ); + let relation = state + .local + .relations + .get(&object_id("public", "perf_multi_action")) + .expect("baseline relation should remain modeled"); + let safe_migrate::model::relation::RelationOverlay::Present(relation) = relation else { + panic!("failed transaction must not drop the baseline relation"); + }; + assert!( + relation + .columns + .iter() + .all(|column| { column.name != "first_value" && column.name != "second_value" }) + ); + report_elapsed( + "multi_action_failure_restores_the_statement_state", + 3, + elapsed, + ); + } + + #[test] + #[ignore = "manual performance scenario; run with --ignored --nocapture"] + fn repeated_savepoint_rollbacks_leave_no_transient_relations() { + let engine = setup_engine(); + let mut state = setup_state(); + let mut sql = String::from("BEGIN;\n"); + for index in 0..SAVEPOINT_ITERATIONS { + sql.push_str(&format!( + "SAVEPOINT checkpoint_{index};\nCREATE TABLE IF NOT EXISTS perf_savepoint_{index} (id bigint NOT NULL);\nROLLBACK TO SAVEPOINT checkpoint_{index};\n" + )); + } + sql.push_str("COMMIT;"); + + let started = Instant::now(); + let findings = engine + .analyze(&sql, &mut state) + .expect("savepoint scenario should analyze"); + let elapsed = started.elapsed(); + + assert!(findings.is_empty(), "unexpected findings: {findings:?}"); + assert!(!state.relation_is_present(&object_id("public", "perf_savepoint_0"))); + assert!(!state.relation_is_present(&object_id("public", "perf_savepoint_249"))); + report_elapsed( + "repeated_savepoint_rollbacks_leave_no_transient_relations", + SAVEPOINT_ITERATIONS * 3 + 2, + elapsed, + ); + } +}