Create dependency_firewall_prevented_packages table
What does this MR do and why?
Part 1 of 5 for gitlab-org/gitlab#627538. It adds the dependency_firewall_prevented_packages table, which will hold one row per (project, rule type, package purl), recording when the Dependency Firewall last blocked or warned on a given package. Nothing writes to this table yet — Parts 2 and 3 branch off this work, and Parts 4-5 stack on those.
Columns: project_id, rule_type (smallint), severity (smallint, nullable), identifier (text, limit 512 — holds a full purl like pkg:type/name@version; the limit matches pm_malware_affected_packages), first_seen_at (not null), last_blocked_at (nullable), last_warned_at (nullable), created_at, updated_at.
Indexes:
- unique
i_dep_fw_prevented_packages_uniqueon(project_id, rule_type, identifier) i_dep_fw_prevented_packages_blockedon(project_id, rule_type, last_blocked_at)i_dep_fw_prevented_packages_warnedon(project_id, rule_type, last_warned_at)
CHECK constraints:
check_dep_fw_prevented_packages_severity_rule_type:severity IS NULL OR rule_type IN (1, 4)— severity is only meaningful for vulnerability-family rules.check_dep_fw_prevented_packages_timestamps:num_nonnulls(last_blocked_at, last_warned_at) >= 1— a row with neither timestamp would be invisible to both read scopes while still occupying the unique ledger slot.
The design assumes dashboard windows always end at "now", so last_blocked_at >= from answers "was this package prevented in the window" with an index read instead of a scan over enforcement events. Warned-only means warned in the window but never blocked in that same window.
The FK to projects is added in a separate migration, concurrently, with ON DELETE CASCADE.
Two bookkeeping files come with the table:
db/docs/data_retention/dependency_firewall_prevented_packages.yml:retention_window: 90,enforcement_strategy: delete_rows,enforcing: false,pause_mechanism: none. Required for every new table in an eligible schema by the Data Retention Policy Framework.delete_rowsrather thandrop_partitionbecause the table is upserted in place — the unique index is the upsert conflict target and the statement advances the timestamp columns, so no time column can serve as a partition key. The table is narrow, low-traffic, with three secondary indexes.enforcing: falseuntil a cleanup worker lands; justification is recorded on the work item.spec/support/database/destroy_service_foreign_keys_todo.yml: grandfathersdependency_firewall_prevented_packages.project_idunderprojects:. A guard spec requires every new FK into a destroy-service table to be either handled at application level or declared cascade-only. Cascade-only is correct here: there is no object storage, counters, or audit events forProjects::DestroyServiceto clean up, and the table's ClickHouse mirror collapses the cascade delete via_siphon_deleted.
The model, its scopes, and the dependency_firewall_dashboard_v2 feature flag follow in a chained MR off this branch.
How to set up and validate locally
bin/rails db:migrate- Inspect the table and its constraints in
psql - Run
bin/rails db:rollback STEP=2and confirm both migrations reverse cleanly
Migration output (main database)
main: == 20260907130002 AddDependencyFirewallPreventedPackagesProjectFk: reverting ==
main: == 20260907130002 AddDependencyFirewallPreventedPackagesProjectFk: reverted (0.0556s)
main: == 20260907130001 CreateDependencyFirewallPreventedPackages: reverting ========
main: == 20260907130001 CreateDependencyFirewallPreventedPackages: reverted (0.0388s)
main: == 20260907130001 CreateDependencyFirewallPreventedPackages: migrating ========
main: == 20260907130001 CreateDependencyFirewallPreventedPackages: migrated (0.0480s)
main: == 20260907130002 AddDependencyFirewallPreventedPackagesProjectFk: migrating ==
main: == 20260907130002 AddDependencyFirewallPreventedPackagesProjectFk: migrated (0.0552s)Database review
Plans measured on a Database Lab clone of gitlab.com: 4,686,200 rows over 208,924 projects, with gitlab-org (namespace 9970) at 2,686,500 rows across 8,951 projects in 1,586 groups, assuming 300 packages per project. Medians of 5 runs.
All three indexes are exercised by their intended query. The group-level read uses i_dep_fw_prevented_packages_blocked with Index Cond on all three columns, via a nested loop seeded from the projects side of the EXISTS. Project-level reads are 8.9 ms. The upsert uses i_dep_fw_prevented_packages_unique as its conflict arbiter.
| Config | Timestamp indexes | Size | Group blocked | Group warned-only | Upsert 50k |
|---|---|---|---|---|---|
| A (shipping) | (p,r,blocked) + (p,r,warned) |
256 MB | 706 ms | 316 ms | 947 ms |
| B (merged) | (p,r,blocked,warned) |
222 MB | 731 ms | 432 ms | 900 ms |
| C (covering) | (p,r,blocked,identifier) + (p,r,warned) |
400 MB | 541 ms | 335 ms | 1126 ms |
| D (both covering) | both widened | 596 MB | 561 ms | 478 ms | 1177 ms |
| baseline | none | 0 | 1599 ms | 1279 ms | 992 ms |
- Two timestamp indexes rather than one merged index: the warned-only query ranges on both timestamps, so
last_warned_atstops being an index bound when it sits fourth (B costs 37% there to save 34 MB and ~5% on writes). D doesn't become index-only either, because that query also filterslast_blocked_at. - Index maintenance is not what dominates the write path: the whole spread is 900-1126 ms per 50k-row upsert, and the no-timestamp-index baseline (992 ms) falls between A and C. Against a 5-minute cron flush that is ~0.3% duty cycle.
- C, appending
identifierto the blocked index, would make the group read an Index Only Scan at 541 ms for +144 MB. Held pending review, since group reads are slated to move to ClickHouse in Parts 3-4.
No configuration meets the 100 ms guideline; 541 ms is the floor. The residual is volume rather than indexing — 309,019 rows scanned and quicksorted to return one integer, with the planner estimating rows=4.
Caveats: 300 packages per project is an assumption and the seed yields only 300 distinct identifiers, so treat the absolutes as an upper bound; gitlab-org is not the worst case (namespace 128823565 has 107,919 descendant groups against 1,586); Database Lab "warm" means fully cached.
New-table questions
- Growth: ~245 bytes per row including indexes, so growth is rows × 245 bytes. Row count is projects using the firewall × distinct packages × rule types triggered — assumption-driven pre-GA.
- Reads and writes per hour: writes are not in the request path — a batched cron flush every 5 minutes (Part 2), at most 12 statements per hour instance-wide. A row is updated when an already-known (project, rule type, purl) is blocked or warned again. Reads are one aggregate set per dashboard load, human-driven.
- Availability risk: low. No request-path writes; reads are index-backed aggregates over a deduplicated table. Group reads move to a ClickHouse replica in Parts 3-4, with PostgreSQL as source of truth and the Self-Managed fallback.
- Retention:
delete_rowsvia a cron cleanup worker,enforcing: falseuntil it lands. db:gitlabcom-database-testingreportedVALIDATE CONSTRAINT fk_94075635ffat 107.07 ms against the 100 ms guideline.