From 7ed9d0b81eab4101f93c0de927c15755ee8b1b49 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 16:16:35 -0500 Subject: [PATCH 1/3] feat(schema): foreign keys for every table reference, with orphan quarantine Changesets 12-26 add a foreign key for each possible-value reference between the QBit's tables, named fk__: - lab_finding.run_id, lab_report.run_id -> lab_run: CASCADE (owned detail) - comparison.change_event_id -> change_event: CASCADE (computed for the change) - regression.cause_change_event_id, lab_run.change_event_id -> change_event, regression.first_run_id / checkpoint_run_id -> lab_run: SET NULL (pointers) - lab_run / lab_finding / regression / comparison .target_id -> target and regression.budget_id -> budget: RESTRICT (history is kept, D8; pages and budgets are disabled, not deleted; the QBit never deletes either) Each changeset first records parent-less rows in qqq_fk_quarantine and nulls them (or, for the NOT NULL run_id columns, copies the row as JSON and deletes it, PostgreSQL only), and is marked ran when the constraint already exists. Changeset 12 indexes the foreign key columns that had no leading index. WebPerfForeignKeyTest runs the changelog with Liquibase on PostgreSQL (Testcontainers), checks the orphan path and re-run idempotency, and fails when a table reference in the meta-data has no constraint. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + pom.xml | 27 ++ .../web_performance.changelog.sql | 151 +++++++++++ .../qbits/webperf/WebPerfForeignKeyTest.java | 245 ++++++++++++++++++ .../qbits/webperf/WebPerfTestSupport.java | 11 +- 5 files changed, 434 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/kingsrook/qbits/webperf/WebPerfForeignKeyTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 80df2fc..f8a38a2 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. - Record deploy and external site changes once, queue settled retests, and attribute lab runs to the observed build and CDN stage. - Evaluate enabled lab and URL CrUX budgets; retain breaches, confirmation evidence, automatic recovery and accurate open counts in the standard regression records. - Confirm statistical lab regressions with a median of three, reset baselines on Lighthouse major changes, and detect deterministic category/audit failures. diff --git a/pom.xml b/pom.xml index 07efbfb..abd5dbc 100644 --- a/pom.xml +++ b/pom.xml @@ -22,6 +22,8 @@ 21 21 0.8.15 + 4.33.0 + 1.21.4 @@ -93,6 +95,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..021ca12 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/webperf/WebPerfForeignKeyTest.java @@ -0,0 +1,245 @@ +/* + * 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()) + { + QTableMetaData right = qInstance.getTable(join.getRightTable()); + join.getJoinOns().forEach(on -> references.add(join.getRightTable() + "." + 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/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; } -- 2.54.0 From fb856fdb39dd1f535c6c8f2090e2ce09bd6b4da7 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 16:35:13 -0500 Subject: [PATCH 2/3] test(schema): take a join's referencing side from its key field A join's child column is on the side whose field is not the primary key, so a many-to-one join (child -> parent) is checked on the child too. Co-Authored-By: Claude Opus 5.5 --- .../kingsrook/qbits/webperf/WebPerfForeignKeyTest.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/kingsrook/qbits/webperf/WebPerfForeignKeyTest.java b/src/test/java/com/kingsrook/qbits/webperf/WebPerfForeignKeyTest.java index 021ca12..44212e5 100644 --- a/src/test/java/com/kingsrook/qbits/webperf/WebPerfForeignKeyTest.java +++ b/src/test/java/com/kingsrook/qbits/webperf/WebPerfForeignKeyTest.java @@ -194,8 +194,14 @@ class WebPerfForeignKeyTest } 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(join.getRightTable() + "." + right.getField(on.getRightField()).getBackendName())); + 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); -- 2.54.0 From 588ae52cd925cc696a2cbb8e1d6de1361a0744f5 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 17:51:18 -0500 Subject: [PATCH 3/3] test(reports): give the old retention report a real run The foreign key on lab_report.run_id rejects develop's placeholder run 999999, so the 40-day-old report now belongs to an inserted FAILED run. Co-Authored-By: Claude Opus 5.5 --- .../java/com/kingsrook/qbits/webperf/WebPerfP1Test.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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"); } -- 2.54.0