diff --git a/CHANGELOG.md b/CHANGELOG.md index 5545a6b..9220d17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project are documented here. The format follows Keep - Allow manual Tested Pages inserts with a URL. Validate absolute HTTPS URLs and fill in the site, path and Manual source automatically; validate URL edits too. ### Added +- Foreign keys for every reference between the QBit's tables (changesets 12-26): findings and reports cascade with their run, comparisons with their site change; optional pointers (likely cause, first and confirmation run, a run's site change) are set to NULL; tested pages and budgets with history cannot be deleted (disable them instead). Rows whose parent is already missing are recorded in `qqq_fk_quarantine`, then nulled or (NOT NULL columns, PostgreSQL) deleted, so the upgrade never fails. Foreign key columns are indexed. A PostgreSQL guardrail test fails when a new reference lacks its constraint. - Every `web_performance_*` table and the Performance app carry help saying what the table is for and how its rows get created (`help/web-performance.yaml`, applied by `WebPerfHelp`; host help is never overridden). A Setup card at the top of the home lists tested pages, the lab runner, budgets (reference defaults or your own) and Google field data, each with its state and next step, using the runtime's own resolution. `WebPerfTablesGuardrailTest` fails when a table is unused, unexplained or lacks that help; `WebPerfDefaultsChangelogTest` runs the shipped changelog and its seeded budgets through real Liquibase. - `reportRetentionDays` is now enforced: with a value above 0, Lighthouse Reports older than that many days are deleted after a result is stored, in batches, at most once an hour per process (runs, scores and findings stay). It was documented but nothing read it; the default 0 still keeps everything. - Record deploy and external site changes once, queue settled retests, and attribute lab runs to the observed build and CDN stage. diff --git a/pom.xml b/pom.xml index 25e0703..0435ddf 100644 --- a/pom.xml +++ b/pom.xml @@ -22,6 +22,8 @@ 21 21 0.8.15 + 4.33.0 + 1.21.4 @@ -100,6 +102,31 @@ 3.27.7 test + + + org.liquibase + liquibase-core + ${liquibase.version} + test + + + org.postgresql + postgresql + 42.7.14 + test + + + org.testcontainers + postgresql + ${testcontainers.version} + test + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + diff --git a/src/main/resources/db/liquibase/qbit-web-performance/web_performance.changelog.sql b/src/main/resources/db/liquibase/qbit-web-performance/web_performance.changelog.sql index 3c08e3d..9f9ac93 100644 --- a/src/main/resources/db/liquibase/qbit-web-performance/web_performance.changelog.sql +++ b/src/main/resources/db/liquibase/qbit-web-performance/web_performance.changelog.sql @@ -280,3 +280,154 @@ ALTER TABLE ${web_performance.tablePrefix}regression ADD COLUMN failed_audits VA ALTER TABLE ${web_performance.tablePrefix}lab_run ADD COLUMN ttfb_ms INTEGER; ALTER TABLE ${web_performance.tablePrefix}field_snapshot ADD COLUMN evaluated_at TIMESTAMP WITHOUT TIME ZONE; ALTER TABLE ${web_performance.tablePrefix}change_event ADD COLUMN last_seen_at TIMESTAMP WITHOUT TIME ZONE; + +-- changeset qbit-web-performance:12 +-- comment: Referential integrity (foreign keys), part 1: every foreign key column leads an index, so a parent delete checks its children by index. The other foreign key columns already lead one (target_id, run_id, change_event_id on comparison). +CREATE INDEX IF NOT EXISTS idx_${web_performance.tablePrefix}lab_run_change_event ON ${web_performance.tablePrefix}lab_run (change_event_id); +CREATE INDEX IF NOT EXISTS idx_${web_performance.tablePrefix}regression_budget ON ${web_performance.tablePrefix}regression (budget_id); +CREATE INDEX IF NOT EXISTS idx_${web_performance.tablePrefix}regression_cause ON ${web_performance.tablePrefix}regression (cause_change_event_id); +CREATE INDEX IF NOT EXISTS idx_${web_performance.tablePrefix}regression_first_run ON ${web_performance.tablePrefix}regression (first_run_id); +CREATE INDEX IF NOT EXISTS idx_${web_performance.tablePrefix}regression_checkpoint_run ON ${web_performance.tablePrefix}regression (checkpoint_run_id); +CREATE INDEX IF NOT EXISTS idx_${web_performance.tablePrefix}comparison_target ON ${web_performance.tablePrefix}comparison (target_id); + +-- Referential integrity (foreign keys), part 2: one changeset per foreign key, named +-- fk__. Each first records and repairs rows whose parent is +-- missing (a nullable column is set to NULL; a NOT NULL row is copied in full to +-- qqq_fk_quarantine and deleted, PostgreSQL only), then adds the constraint. Each +-- is marked ran when its constraint already exists (a host that added it itself). +-- ON DELETE: owned details CASCADE (findings and the report of a run, +-- comparisons of a site change); optional pointers SET NULL; tested pages and +-- budgets RESTRICT, because history is kept (D8): disable a page or budget instead +-- of deleting it. + +-- changeset qbit-web-performance:13 +-- comment: Lab runs keep their tested page (D8, history is kept): a page with runs cannot be deleted, disable it instead (enabled = false). Orphan target_id values are recorded in qqq_fk_quarantine and set to NULL. +-- preconditions onFail:MARK_RAN onError:HALT +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_${web_performance.tablePrefix}lab_run_target_id' +CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP); +INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, changeset) SELECT '${web_performance.tablePrefix}lab_run', 'target_id', CAST(c.id AS VARCHAR(100)), CAST(c.target_id AS VARCHAR(100)), 'NULLED', 'qbit-web-performance:13' FROM ${web_performance.tablePrefix}lab_run c WHERE c.target_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}target p WHERE p.id = c.target_id); +UPDATE ${web_performance.tablePrefix}lab_run c SET target_id = NULL WHERE c.target_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}target p WHERE p.id = c.target_id); +ALTER TABLE ${web_performance.tablePrefix}lab_run ADD CONSTRAINT fk_${web_performance.tablePrefix}lab_run_target_id FOREIGN KEY (target_id) REFERENCES ${web_performance.tablePrefix}target (id) ON DELETE RESTRICT; +-- rollback ALTER TABLE ${web_performance.tablePrefix}lab_run DROP CONSTRAINT IF EXISTS fk_${web_performance.tablePrefix}lab_run_target_id; + +-- changeset qbit-web-performance:14 dbms:postgresql +-- comment: Orphan cleanup before fk_${web_performance.tablePrefix}lab_finding_run_id: ${web_performance.tablePrefix}lab_finding rows whose run_id names a missing ${web_performance.tablePrefix}lab_run row are copied in full to qqq_fk_quarantine, then deleted (run_id is NOT NULL). +-- preconditions onFail:MARK_RAN onError:HALT +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_${web_performance.tablePrefix}lab_finding_run_id' +CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP); +INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT '${web_performance.tablePrefix}lab_finding', 'run_id', CAST(c.id AS VARCHAR(100)), CAST(c.run_id AS VARCHAR(100)), 'DELETED', row_to_json(c)::text, 'qbit-web-performance:14' FROM ${web_performance.tablePrefix}lab_finding c WHERE c.run_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}lab_run p WHERE p.id = c.run_id); +DELETE FROM ${web_performance.tablePrefix}lab_finding c WHERE c.run_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}lab_run p WHERE p.id = c.run_id); +-- rollback SELECT 1; + +-- changeset qbit-web-performance:15 +-- comment: Findings belong to their run: deleting a run deletes its findings. +-- preconditions onFail:MARK_RAN onError:HALT +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_${web_performance.tablePrefix}lab_finding_run_id' +ALTER TABLE ${web_performance.tablePrefix}lab_finding ADD CONSTRAINT fk_${web_performance.tablePrefix}lab_finding_run_id FOREIGN KEY (run_id) REFERENCES ${web_performance.tablePrefix}lab_run (id) ON DELETE CASCADE; +-- rollback ALTER TABLE ${web_performance.tablePrefix}lab_finding DROP CONSTRAINT IF EXISTS fk_${web_performance.tablePrefix}lab_finding_run_id; + +-- changeset qbit-web-performance:16 +-- comment: A finding's page (copied from its run) is kept like the run's. Orphan target_id values are recorded in qqq_fk_quarantine and set to NULL. +-- preconditions onFail:MARK_RAN onError:HALT +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_${web_performance.tablePrefix}lab_finding_target_id' +CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP); +INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, changeset) SELECT '${web_performance.tablePrefix}lab_finding', 'target_id', CAST(c.id AS VARCHAR(100)), CAST(c.target_id AS VARCHAR(100)), 'NULLED', 'qbit-web-performance:16' FROM ${web_performance.tablePrefix}lab_finding c WHERE c.target_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}target p WHERE p.id = c.target_id); +UPDATE ${web_performance.tablePrefix}lab_finding c SET target_id = NULL WHERE c.target_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}target p WHERE p.id = c.target_id); +ALTER TABLE ${web_performance.tablePrefix}lab_finding ADD CONSTRAINT fk_${web_performance.tablePrefix}lab_finding_target_id FOREIGN KEY (target_id) REFERENCES ${web_performance.tablePrefix}target (id) ON DELETE RESTRICT; +-- rollback ALTER TABLE ${web_performance.tablePrefix}lab_finding DROP CONSTRAINT IF EXISTS fk_${web_performance.tablePrefix}lab_finding_target_id; + +-- changeset qbit-web-performance:17 dbms:postgresql +-- comment: Orphan cleanup before fk_${web_performance.tablePrefix}lab_report_run_id: ${web_performance.tablePrefix}lab_report rows whose run_id names a missing ${web_performance.tablePrefix}lab_run row are copied in full to qqq_fk_quarantine, then deleted (run_id is NOT NULL). +-- preconditions onFail:MARK_RAN onError:HALT +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_${web_performance.tablePrefix}lab_report_run_id' +CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP); +INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT '${web_performance.tablePrefix}lab_report', 'run_id', CAST(c.id AS VARCHAR(100)), CAST(c.run_id AS VARCHAR(100)), 'DELETED', row_to_json(c)::text, 'qbit-web-performance:17' FROM ${web_performance.tablePrefix}lab_report c WHERE c.run_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}lab_run p WHERE p.id = c.run_id); +DELETE FROM ${web_performance.tablePrefix}lab_report c WHERE c.run_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}lab_run p WHERE p.id = c.run_id); +-- rollback SELECT 1; + +-- changeset qbit-web-performance:18 +-- comment: The Lighthouse report belongs to its run: deleting a run deletes its report. +-- preconditions onFail:MARK_RAN onError:HALT +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_${web_performance.tablePrefix}lab_report_run_id' +ALTER TABLE ${web_performance.tablePrefix}lab_report ADD CONSTRAINT fk_${web_performance.tablePrefix}lab_report_run_id FOREIGN KEY (run_id) REFERENCES ${web_performance.tablePrefix}lab_run (id) ON DELETE CASCADE; +-- rollback ALTER TABLE ${web_performance.tablePrefix}lab_report DROP CONSTRAINT IF EXISTS fk_${web_performance.tablePrefix}lab_report_run_id; + +-- changeset qbit-web-performance:19 +-- comment: Regressions keep their page: a page with regressions cannot be deleted, disable it instead. Orphan target_id values are recorded in qqq_fk_quarantine and set to NULL. +-- preconditions onFail:MARK_RAN onError:HALT +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_${web_performance.tablePrefix}regression_target_id' +CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP); +INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, changeset) SELECT '${web_performance.tablePrefix}regression', 'target_id', CAST(c.id AS VARCHAR(100)), CAST(c.target_id AS VARCHAR(100)), 'NULLED', 'qbit-web-performance:19' FROM ${web_performance.tablePrefix}regression c WHERE c.target_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}target p WHERE p.id = c.target_id); +UPDATE ${web_performance.tablePrefix}regression c SET target_id = NULL WHERE c.target_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}target p WHERE p.id = c.target_id); +ALTER TABLE ${web_performance.tablePrefix}regression ADD CONSTRAINT fk_${web_performance.tablePrefix}regression_target_id FOREIGN KEY (target_id) REFERENCES ${web_performance.tablePrefix}target (id) ON DELETE RESTRICT; +-- rollback ALTER TABLE ${web_performance.tablePrefix}regression DROP CONSTRAINT IF EXISTS fk_${web_performance.tablePrefix}regression_target_id; + +-- changeset qbit-web-performance:20 +-- comment: A regression keeps the budget it breached: a budget with regressions cannot be deleted, disable it instead (enabled = false). Orphan budget_id values are recorded in qqq_fk_quarantine and set to NULL. +-- preconditions onFail:MARK_RAN onError:HALT +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_${web_performance.tablePrefix}regression_budget_id' +CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP); +INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, changeset) SELECT '${web_performance.tablePrefix}regression', 'budget_id', CAST(c.id AS VARCHAR(100)), CAST(c.budget_id AS VARCHAR(100)), 'NULLED', 'qbit-web-performance:20' FROM ${web_performance.tablePrefix}regression c WHERE c.budget_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}budget p WHERE p.id = c.budget_id); +UPDATE ${web_performance.tablePrefix}regression c SET budget_id = NULL WHERE c.budget_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}budget p WHERE p.id = c.budget_id); +ALTER TABLE ${web_performance.tablePrefix}regression ADD CONSTRAINT fk_${web_performance.tablePrefix}regression_budget_id FOREIGN KEY (budget_id) REFERENCES ${web_performance.tablePrefix}budget (id) ON DELETE RESTRICT; +-- rollback ALTER TABLE ${web_performance.tablePrefix}regression DROP CONSTRAINT IF EXISTS fk_${web_performance.tablePrefix}regression_budget_id; + +-- changeset qbit-web-performance:21 +-- comment: The likely cause is an optional pointer: deleting the site change clears it. Orphan values are recorded in qqq_fk_quarantine and set to NULL. +-- preconditions onFail:MARK_RAN onError:HALT +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_${web_performance.tablePrefix}regression_cause_change_event_id' +CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP); +INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, changeset) SELECT '${web_performance.tablePrefix}regression', 'cause_change_event_id', CAST(c.id AS VARCHAR(100)), CAST(c.cause_change_event_id AS VARCHAR(100)), 'NULLED', 'qbit-web-performance:21' FROM ${web_performance.tablePrefix}regression c WHERE c.cause_change_event_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}change_event p WHERE p.id = c.cause_change_event_id); +UPDATE ${web_performance.tablePrefix}regression c SET cause_change_event_id = NULL WHERE c.cause_change_event_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}change_event p WHERE p.id = c.cause_change_event_id); +ALTER TABLE ${web_performance.tablePrefix}regression ADD CONSTRAINT fk_${web_performance.tablePrefix}regression_cause_change_event_id FOREIGN KEY (cause_change_event_id) REFERENCES ${web_performance.tablePrefix}change_event (id) ON DELETE SET NULL; +-- rollback ALTER TABLE ${web_performance.tablePrefix}regression DROP CONSTRAINT IF EXISTS fk_${web_performance.tablePrefix}regression_cause_change_event_id; + +-- changeset qbit-web-performance:22 +-- comment: A before-and-after comparison is computed for its site change: deleting the change deletes its comparisons. Orphan values are recorded in qqq_fk_quarantine and set to NULL. +-- preconditions onFail:MARK_RAN onError:HALT +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_${web_performance.tablePrefix}comparison_change_event_id' +CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP); +INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, changeset) SELECT '${web_performance.tablePrefix}comparison', 'change_event_id', CAST(c.id AS VARCHAR(100)), CAST(c.change_event_id AS VARCHAR(100)), 'NULLED', 'qbit-web-performance:22' FROM ${web_performance.tablePrefix}comparison c WHERE c.change_event_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}change_event p WHERE p.id = c.change_event_id); +UPDATE ${web_performance.tablePrefix}comparison c SET change_event_id = NULL WHERE c.change_event_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}change_event p WHERE p.id = c.change_event_id); +ALTER TABLE ${web_performance.tablePrefix}comparison ADD CONSTRAINT fk_${web_performance.tablePrefix}comparison_change_event_id FOREIGN KEY (change_event_id) REFERENCES ${web_performance.tablePrefix}change_event (id) ON DELETE CASCADE; +-- rollback ALTER TABLE ${web_performance.tablePrefix}comparison DROP CONSTRAINT IF EXISTS fk_${web_performance.tablePrefix}comparison_change_event_id; + +-- changeset qbit-web-performance:23 +-- comment: The site change a run was attributed to is an optional pointer: deleting the change clears it. Orphan values are recorded in qqq_fk_quarantine and set to NULL. +-- preconditions onFail:MARK_RAN onError:HALT +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_${web_performance.tablePrefix}lab_run_change_event_id' +CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP); +INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, changeset) SELECT '${web_performance.tablePrefix}lab_run', 'change_event_id', CAST(c.id AS VARCHAR(100)), CAST(c.change_event_id AS VARCHAR(100)), 'NULLED', 'qbit-web-performance:23' FROM ${web_performance.tablePrefix}lab_run c WHERE c.change_event_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}change_event p WHERE p.id = c.change_event_id); +UPDATE ${web_performance.tablePrefix}lab_run c SET change_event_id = NULL WHERE c.change_event_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}change_event p WHERE p.id = c.change_event_id); +ALTER TABLE ${web_performance.tablePrefix}lab_run ADD CONSTRAINT fk_${web_performance.tablePrefix}lab_run_change_event_id FOREIGN KEY (change_event_id) REFERENCES ${web_performance.tablePrefix}change_event (id) ON DELETE SET NULL; +-- rollback ALTER TABLE ${web_performance.tablePrefix}lab_run DROP CONSTRAINT IF EXISTS fk_${web_performance.tablePrefix}lab_run_change_event_id; + +-- changeset qbit-web-performance:24 +-- comment: Evidence pointer to the run that first showed the regression: deleting the run clears it. Orphan values are recorded in qqq_fk_quarantine and set to NULL. +-- preconditions onFail:MARK_RAN onError:HALT +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_${web_performance.tablePrefix}regression_first_run_id' +CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP); +INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, changeset) SELECT '${web_performance.tablePrefix}regression', 'first_run_id', CAST(c.id AS VARCHAR(100)), CAST(c.first_run_id AS VARCHAR(100)), 'NULLED', 'qbit-web-performance:24' FROM ${web_performance.tablePrefix}regression c WHERE c.first_run_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}lab_run p WHERE p.id = c.first_run_id); +UPDATE ${web_performance.tablePrefix}regression c SET first_run_id = NULL WHERE c.first_run_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}lab_run p WHERE p.id = c.first_run_id); +ALTER TABLE ${web_performance.tablePrefix}regression ADD CONSTRAINT fk_${web_performance.tablePrefix}regression_first_run_id FOREIGN KEY (first_run_id) REFERENCES ${web_performance.tablePrefix}lab_run (id) ON DELETE SET NULL; +-- rollback ALTER TABLE ${web_performance.tablePrefix}regression DROP CONSTRAINT IF EXISTS fk_${web_performance.tablePrefix}regression_first_run_id; + +-- changeset qbit-web-performance:25 +-- comment: Evidence pointer to the confirmation run: deleting the run clears it. Orphan values are recorded in qqq_fk_quarantine and set to NULL. +-- preconditions onFail:MARK_RAN onError:HALT +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_${web_performance.tablePrefix}regression_checkpoint_run_id' +CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP); +INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, changeset) SELECT '${web_performance.tablePrefix}regression', 'checkpoint_run_id', CAST(c.id AS VARCHAR(100)), CAST(c.checkpoint_run_id AS VARCHAR(100)), 'NULLED', 'qbit-web-performance:25' FROM ${web_performance.tablePrefix}regression c WHERE c.checkpoint_run_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}lab_run p WHERE p.id = c.checkpoint_run_id); +UPDATE ${web_performance.tablePrefix}regression c SET checkpoint_run_id = NULL WHERE c.checkpoint_run_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}lab_run p WHERE p.id = c.checkpoint_run_id); +ALTER TABLE ${web_performance.tablePrefix}regression ADD CONSTRAINT fk_${web_performance.tablePrefix}regression_checkpoint_run_id FOREIGN KEY (checkpoint_run_id) REFERENCES ${web_performance.tablePrefix}lab_run (id) ON DELETE SET NULL; +-- rollback ALTER TABLE ${web_performance.tablePrefix}regression DROP CONSTRAINT IF EXISTS fk_${web_performance.tablePrefix}regression_checkpoint_run_id; + +-- changeset qbit-web-performance:26 +-- comment: A per-page comparison keeps its page: a page with comparisons cannot be deleted, disable it instead. Orphan target_id values are recorded in qqq_fk_quarantine and set to NULL. +-- preconditions onFail:MARK_RAN onError:HALT +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_${web_performance.tablePrefix}comparison_target_id' +CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP); +INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, changeset) SELECT '${web_performance.tablePrefix}comparison', 'target_id', CAST(c.id AS VARCHAR(100)), CAST(c.target_id AS VARCHAR(100)), 'NULLED', 'qbit-web-performance:26' FROM ${web_performance.tablePrefix}comparison c WHERE c.target_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}target p WHERE p.id = c.target_id); +UPDATE ${web_performance.tablePrefix}comparison c SET target_id = NULL WHERE c.target_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${web_performance.tablePrefix}target p WHERE p.id = c.target_id); +ALTER TABLE ${web_performance.tablePrefix}comparison ADD CONSTRAINT fk_${web_performance.tablePrefix}comparison_target_id FOREIGN KEY (target_id) REFERENCES ${web_performance.tablePrefix}target (id) ON DELETE RESTRICT; +-- rollback ALTER TABLE ${web_performance.tablePrefix}comparison DROP CONSTRAINT IF EXISTS fk_${web_performance.tablePrefix}comparison_target_id; diff --git a/src/test/java/com/kingsrook/qbits/webperf/WebPerfForeignKeyTest.java b/src/test/java/com/kingsrook/qbits/webperf/WebPerfForeignKeyTest.java new file mode 100644 index 0000000..44212e5 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/webperf/WebPerfForeignKeyTest.java @@ -0,0 +1,251 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.webperf; + + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import liquibase.Contexts; +import liquibase.LabelExpression; +import liquibase.Liquibase; +import liquibase.database.Database; +import liquibase.database.DatabaseFactory; +import liquibase.database.jvm.JdbcConnection; +import liquibase.resource.ClassLoaderResourceAccessor; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Referential integrity on PostgreSQL (the production database), with the + ** changelog run by Liquibase as a host runs it: + ** + ** - every table possible-value field that names one of the QBit's tables, + ** and every join, has a database foreign key (or an exemption with a + ** reason), so a new reference cannot be added without its constraint; + ** - rows whose parent is missing when the constraints are added are recorded + ** in qqq_fk_quarantine and repaired (nulled, or deleted with a full copy), + ** so the upgrade never fails on existing data; + ** - the foreign key changesets are no-ops on a database that already has the + ** constraints (a host that added them itself). + ** + ** Skipped when Docker is not available. + *******************************************************************************/ +@Testcontainers(disabledWithoutDocker = true) +class WebPerfForeignKeyTest +{ + private static final String PREFIX = "web_performance_"; + + ///////////////////////////////////////////////////////////////////////////// + // references that are deliberately not foreign keys: "child.column" -> // + // reason. None today (content_table + content_id is polymorphic, and has // + // no possible-value source). // + ///////////////////////////////////////////////////////////////////////////// + private static final Map EXEMPT = Map.of(); + + ///////////////////////////////////////////////////////////////////////////// + // the changesets before the foreign keys (1-11) // + ///////////////////////////////////////////////////////////////////////////// + private static final int CHANGESETS_BEFORE_FOREIGN_KEYS = 11; + + @Container + private static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:17-alpine"); + + + + /******************************************************************************* + ** Orphans are quarantined, every reference gets its constraint, and a + ** re-run of the foreign key changesets changes nothing. + *******************************************************************************/ + @Test + void testEveryReferenceHasAForeignKey() throws Exception + { + try(Connection connection = DriverManager.getConnection(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()); + Statement statement = connection.createStatement()) + { + statement.execute("DROP SCHEMA public CASCADE"); + statement.execute("CREATE SCHEMA public"); + + //////////////////////////////////////////////////////////////////////// + // an existing install with rows whose parents are gone // + //////////////////////////////////////////////////////////////////////// + migrate(connection, CHANGESETS_BEFORE_FOREIGN_KEYS); + statement.execute("INSERT INTO web_performance_target (id, url) VALUES (1, 'https://www.example.com/')"); + statement.execute("INSERT INTO web_performance_lab_run (id, target_id) VALUES (1, 1), (2, 404)"); + statement.execute("INSERT INTO web_performance_lab_finding (id, run_id, target_id) VALUES (1, 1, 1), (2, 404, 1)"); + statement.execute("INSERT INTO web_performance_lab_report (id, run_id) VALUES (1, 404)"); + statement.execute("INSERT INTO web_performance_regression (id, target_id, budget_id, first_run_id) VALUES (1, 1, 404, 404)"); + + migrate(connection, null); + + //////////////////////////////////////////////////////////////////////// + // every reference the meta-data declares has its constraint // + //////////////////////////////////////////////////////////////////////// + Map foreignKeys = foreignKeys(statement); + List missing = new ArrayList<>(); + for(String reference : references()) + { + if(!foreignKeys.containsKey(reference) && !EXEMPT.containsKey(reference)) + { + missing.add(reference); + } + } + assertThat(missing).as("references without a foreign key (add one to the changelog, or an exemption with a reason)").isEmpty(); + assertThat(foreignKeys).containsEntry("web_performance_lab_finding.run_id", "web_performance_lab_run CASCADE") + .containsEntry("web_performance_lab_run.target_id", "web_performance_target RESTRICT") + .containsEntry("web_performance_regression.cause_change_event_id", "web_performance_change_event SET NULL"); + + //////////////////////////////////////////////////////////////////////// + // the orphans were recorded, then nulled or deleted // + //////////////////////////////////////////////////////////////////////// + assertThat(rows(statement, "SELECT table_name || '.' || column_name || ' ' || row_id || '->' || missing_parent_id || ' ' || action || ' ' || (row_data IS NOT NULL) FROM qqq_fk_quarantine")) + .containsExactlyInAnyOrder( + "web_performance_lab_run.target_id 2->404 NULLED false", + "web_performance_lab_finding.run_id 2->404 DELETED true", + "web_performance_lab_report.run_id 1->404 DELETED true", + "web_performance_regression.budget_id 1->404 NULLED false", + "web_performance_regression.first_run_id 1->404 NULLED false"); + assertThat(rows(statement, "SELECT id || ' ' || COALESCE(CAST(target_id AS TEXT), '-') FROM web_performance_lab_run")).containsExactlyInAnyOrder("1 1", "2 -"); + assertThat(rows(statement, "SELECT CAST(id AS TEXT) FROM web_performance_lab_finding")).containsExactly("1"); + assertThat(rows(statement, "SELECT CAST(id AS TEXT) FROM web_performance_lab_report")).isEmpty(); + + //////////////////////////////////////////////////////////////////////// + // a host that already has the constraints: the changesets are marked // + // ran and change nothing // + //////////////////////////////////////////////////////////////////////// + statement.execute("DELETE FROM databasechangelog WHERE CAST(SUBSTRING(id FROM '[0-9]+$') AS INTEGER) > " + CHANGESETS_BEFORE_FOREIGN_KEYS); + migrate(connection, null); + assertThat(foreignKeys(statement)).isEqualTo(foreignKeys); + assertThat(rows(statement, "SELECT CAST(COUNT(*) AS TEXT) FROM qqq_fk_quarantine")).containsExactly("5"); + } + } + + + + /******************************************************************************* + ** Run the changelog (all of it, or the first n changesets). + *******************************************************************************/ + private static void migrate(Connection connection, Integer count) throws Exception + { + Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection)); + Liquibase liquibase = new Liquibase(WebPerfSchema.CHANGELOG, new ClassLoaderResourceAccessor(), database); + liquibase.setChangeLogParameter(WebPerfSchema.CHANGELOG_PREFIX_PROPERTY, PREFIX); + if(count == null) + { + liquibase.update(new Contexts(), new LabelExpression()); + } + else + { + liquibase.update(count, new Contexts(), new LabelExpression()); + } + } + + + + /******************************************************************************* + ** "child.column" for every table possible-value field and join of the QBit. + *******************************************************************************/ + private static List references() throws Exception + { + QInstance qInstance = WebPerfTestSupport.newInstance(); + new WebPerfQBitProducer().withQBitConfig(WebPerfTestSupport.config().withTableNamePrefix(PREFIX)).produce(qInstance); + + List references = new ArrayList<>(); + for(QTableMetaData table : qInstance.getTables().values()) + { + for(QFieldMetaData field : table.getFields().values()) + { + if(field.getPossibleValueSourceName() != null && qInstance.getTable(field.getPossibleValueSourceName()) != null) + { + references.add(table.getName() + "." + field.getBackendName()); + } + } + } + for(QJoinMetaData join : qInstance.getJoins().values()) + { + ///////////////////////////////////////////////////////////////////// + // the referencing side is the one whose field is not its key // + ///////////////////////////////////////////////////////////////////// + QTableMetaData left = qInstance.getTable(join.getLeftTable()); + QTableMetaData right = qInstance.getTable(join.getRightTable()); + join.getJoinOns().forEach(on -> references.add(on.getRightField().equals(right.getPrimaryKeyField()) + ? left.getName() + "." + left.getField(on.getLeftField()).getBackendName() + : right.getName() + "." + right.getField(on.getRightField()).getBackendName())); + } + assertThat(references).as("the QBit's references").hasSizeGreaterThanOrEqualTo(12); + return (references); + } + + + + /******************************************************************************* + ** "child.column" -> "parent RULE" for every foreign key in the database. + *******************************************************************************/ + private static Map foreignKeys(Statement statement) throws Exception + { + Map foreignKeys = new HashMap<>(); + try(ResultSet resultSet = statement.executeQuery(""" + SELECT tc.table_name || '.' || kcu.column_name, ccu.table_name || ' ' || rc.delete_rule + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu ON kcu.constraint_name = tc.constraint_name + JOIN information_schema.referential_constraints rc ON rc.constraint_name = tc.constraint_name + JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name = tc.constraint_name + WHERE tc.constraint_type = 'FOREIGN KEY'""")) + { + while(resultSet.next()) + { + foreignKeys.put(resultSet.getString(1), resultSet.getString(2)); + } + } + return (foreignKeys); + } + + + + /******************************************************************************* + ** The first column of each row of a query, as text. + *******************************************************************************/ + private static List rows(Statement statement, String sql) throws Exception + { + List rows = new ArrayList<>(); + try(ResultSet resultSet = statement.executeQuery(sql)) + { + while(resultSet.next()) + { + rows.add(resultSet.getString(1)); + } + } + return (rows); + } +} diff --git a/src/test/java/com/kingsrook/qbits/webperf/WebPerfP1Test.java b/src/test/java/com/kingsrook/qbits/webperf/WebPerfP1Test.java index ddef6cb..fd12ab9 100644 --- a/src/test/java/com/kingsrook/qbits/webperf/WebPerfP1Test.java +++ b/src/test/java/com/kingsrook/qbits/webperf/WebPerfP1Test.java @@ -247,7 +247,8 @@ class WebPerfP1Test store().update("DELETE FROM {budget}"); config.withReportRetentionDays(days); google.respond(new FixtureServer.Response(200, FixtureServer.fixture("psi-kof22-home-mobile.json"))); - store().insert("lab_report", new java.util.LinkedHashMap<>(Map.of("run_id", 999_999L, "bytes", 1, + long oldRunId = store().insert("lab_run", new java.util.LinkedHashMap<>(Map.of("status", "FAILED"))); + store().insert("lab_report", new java.util.LinkedHashMap<>(Map.of("run_id", oldRunId, "bytes", 1, "create_date", Instant.now().minus(java.time.Duration.ofDays(40))))); WebPerfRuntime.of(qInstance, "web_performance_").allowReportPruneNow(); WebPerfService service = service(); @@ -257,8 +258,8 @@ class WebPerfP1Test assertEquals(2, store().query("SELECT id FROM {lab_run} WHERE status = 'SUCCEEDED'").size(), "runs are untouched by pruning"); assertThat(store().query("SELECT id FROM {lab_finding}")).as("findings are untouched by pruning").hasSizeGreaterThan(5); - long old = store().query("SELECT id FROM {lab_report} WHERE run_id = 999999").size(); - long fresh = store().query("SELECT id FROM {lab_report} WHERE run_id <> 999999").size(); + long old = store().query("SELECT id FROM {lab_report} WHERE run_id = ?", oldRunId).size(); + long fresh = store().query("SELECT id FROM {lab_report} WHERE run_id <> ?", oldRunId).size(); assertEquals(2, fresh, "the new reports are kept"); assertEquals(days == 0 ? 1 : 0, old, "retention " + days + " days"); } diff --git a/src/test/java/com/kingsrook/qbits/webperf/WebPerfTestSupport.java b/src/test/java/com/kingsrook/qbits/webperf/WebPerfTestSupport.java index 63a6ae7..26ea117 100644 --- a/src/test/java/com/kingsrook/qbits/webperf/WebPerfTestSupport.java +++ b/src/test/java/com/kingsrook/qbits/webperf/WebPerfTestSupport.java @@ -127,9 +127,18 @@ public final class WebPerfTestSupport List statements = new ArrayList<>(); StringBuilder current = new StringBuilder(); + boolean skip = false; for(String line : script.split("\n")) { - if(line.trim().startsWith("--")) + ////////////////////////////////////////////////////////////////////// + // a changeset limited to other databases (the dbms:postgresql // + // orphan cleanup) is skipped, as Liquibase skips it on H2 // + ////////////////////////////////////////////////////////////////////// + if(line.startsWith("-- changeset ")) + { + skip = line.contains(" dbms:") && !line.contains("h2"); + } + if(skip || line.trim().startsWith("--")) { continue; }