The problem
A schema migration can look harmless in code review and still stall a service. PostgreSQL can add a column with a constant default without touching existing rows, but a volatile default, one evaluated for each row such as gen_random_uuid(), has to be written into every existing row. So adding a required column with a volatile default rewrites the whole table under AccessExclusiveLock, the strongest table lock, which blocks reads and writes. A normal (non-concurrent) index build also blocks writes. In an application that takes orders, order updates, inserts and even reads wait or time out while that happens. Code review shows the diff, not the lock.
MigrateMeter is for developers who review database migrations and for the site reliability engineers (SREs) and database administrators (DBAs) who keep the application responsive. This first version handles Alembic migrations (Alembic is the migration tool of SQLAlchemy, a common Python database library) on PostgreSQL. It answers one question as a comment on the merge request: what does this migration do to queries that run at the same time?
What it does
On every merge request that adds or changes a migration under app/migrations/versions/, a GitLab CI job runs it against a disposable PostgreSQL 16 database. The job first fills the table with 1,000,000 synthetic orders, then starts four client connections that keep reading and writing, and a monitor connection that samples PostgreSQL's lock table every 50 ms. Each client gives up after waiting 1 s for a lock. The job records the time during which at least one client was waiting on a lock, the lock timeouts, the application errors (counted separately from timeouts), the locks the migration held, and whether the table's physical file changed, which means it was rewritten.
The repository's policy file, .migration-gate.yml, turns those numbers into SAFE, RISKY or BLOCK. By default it blocks more than 500 ms of blocking, any lock timeout, any AccessExclusiveLock on a table over 100,000 rows, and findings of the rules it marks as blocking, such as MG001 (a required column with a volatile default, described above). A table rewrite or an application error makes the result at least RISKY. The file also lists the approvers. A BLOCK fails the job, so the merge request pipeline shows as failed.
A static check runs next to the measurement. Seven rules, MG001 to MG007, each pair a risky migration pattern with a safer one. Three of them (MG001, MG002 and MG004) have a safer pattern that can be applied mechanically; the others need a human-written fix. MG004 flags a new foreign key or check constraint that is validated against existing rows immediately.
Static migration checks read only the code. This project adds two things: the migration runs against concurrent traffic in CI and the report says what those queries experienced, and an agent then closes the loop: it proposes a fix from the measured evidence, a specific approval lets a second agent commit it, and CI measures the result again.
Two GitLab Duo custom flows act on the evidence:
- Review flow, started when a merge request pipeline finishes. It first checks that the pipeline, the job, the commit and the merge request belong together, to avoid explaining a stale or unrelated run. Then it posts the numbers on the merge request. For findings with a mechanical fix, it proposes a diff and prints the exact comment an approver can post to apply it. For other findings, its instructions are to explain the risk and ask for a human-written fix; that case did not come up in the demo. Either way, it stops there.
- Apply flow, started when someone mentions its account in a comment. It acts only when the comment comes from an approver in the policy file on the default branch, so a merge request cannot add its own approver, and only when the comment names the commit and pipeline from the proposal, so the approval covers one specific measurement. The mention event carries the comment and the merge request but not a verified author, so the flow looks the author up through the API. It then re-checks the commit, the report and the proposal, and commits the approved diff to the merge request branch, nothing more.
After that, a person reviews the new measurement, merges, and runs the manual release job. That job records the migration plan, a Markdown file the measurement job writes with the measured verdict and the steps that still have to follow, as a GitLab release on the merged commit. The same person opens a follow-up issue for those steps.
Demo results
Merge request !1 in the hackathon project, measured on GitLab.com on October 24, 2026:
| Before the rewrite | After the rewrite | |
|---|---|---|
| Verdict | BLOCK |
SAFE |
| Time with at least one client blocked | 2,750 ms | 0 ms |
| Blocked client queries | 12 | 0 |
| Lock timeouts (1 s) | 8 | 0 |
| Application errors | 0 | 0 |
| Strongest lock held | AccessExclusiveLock |
ShareUpdateExclusiveLock |
| Table rewritten | yes | no |
The before run failed four blocking checks (the blocking time, the timeouts, the forbidden lock and rule MG001), and the rewrite and rule MG002 (the plain index build) added two risks. The rewrite the review flow proposed adds the column as nullable, sets the default in a separate statement, and builds the index concurrently. ShareUpdateExclusiveLock, the strongest lock left, does not block reads or writes. The apply flow's commit matched the approved diff line for line.
The rewrite leaves old rows with a NULL tracking ID. Filling them in batches (a backfill) and then enforcing NOT NULL could lock or scan the table again, so they belong in later migrations, each measured the same way when its merge request opens. Follow-up issue #2 lists them in order: backfill, add a NOT VALID check (it checks only rows that are inserted or updated), validate it against old rows, then SET NOT NULL.
Why Assisted
The hackathon's Assisted level means the agents propose and a person approves each step. That fits migrations because data changes are hard to undo: the approver authorizes one specific commit, the apply flow writes once, and a person reviews the new measurement, merges and releases. Without a go-ahead, the agents only post explanations and proposals as comments. Changes to the repository, merges and releases wait for a person, and the apply flow's approval check is an agent instruction (see Limits). The measurement itself is a CI job on a disposable database. The flows are also built to stop when evidence is incomplete. In one run the flow's API tool returned an empty body for the job's Markdown report, and the review flow posted "evidence unavailable", with no numbers, no diff and no apply command, instead of guessing.
How it is built
- Measurement and rules: a Python package in the repository. The static rules read the migration's syntax tree without importing it, so the check runs no migration code; migration code runs only in the CI jobs that use the disposable database. The measurement uses
pg_locks, PostgreSQL's view of current locks. The report is written as JSON, Markdown and the migration plan. A minimal FastAPI (Python web framework) application owns theorderstable that the demo migrations change. - CI/CD: test (166 unit tests and lint), secure (GitLab static application security testing and secret detection), verify (a PostgreSQL 16 service for the measurement and an integration test), package (reports and plan to the Generic Package Registry on the default branch) and a manual release stage.
- GitLab Duo Agent Platform: two flows in Flow Registry v1, GitLab's YAML format for custom flows, registered and released through GitLab's GraphQL API, each with its own service account. The review trigger fires only for merge request pipelines that succeeded or failed. Each flow session also runs as a pipeline of its own, and this filter keeps those pipelines from starting the review flow again.
What GitLab.com showed while building it
Running the whole loop on GitLab.com, not only in local tests, changed the design in four places:
- The real database found a bug that the unit tests did not catch. The seed query used a literal
%, which psycopg, the PostgreSQL driver for Python, reads as a parameter marker when parameters are passed. - Flow creation rejects prompts that contain HTML comments. The flows had marked each merge request note with an HTML comment naming the pipeline, job and commit it is about; those markers became plain visible first lines, and a unit test now guards the rejected patterns.
- The flow image has no
pip. The flows use only API tools, so they need no setup script. - GitLab does not start a flow from an event caused by a flow's service account, unless its flow chaining feature is enabled. This is a second safeguard against flow-to-flow loops, separate from the trigger filter, and it is why a person reruns the pipeline to get the fresh review after the apply commit.
Limits
- The workload is synthetic: one table, four clients, a sample every 50 ms. The 0 ms after the rewrite means no client was seen waiting at a sample, not that no query slowed down. The review flow itself noted four UPDATE statements that took about 205 ms instead of 1 to 3 ms around the time the migration's short
AccessExclusiveLockrequest was waiting. This may be a short lock wait that the sampler did not count as blocking. - The flows' checks are instructions to an agent, not a deterministic authorization engine. This first version keeps the whole loop inside GitLab Duo custom flows, so protected branches and the approver list still matter.
What's next
Count waits that a migration causes even when no client is sampled as blocked, move the authorization and patch checks into deterministic tooling, and add other migration frameworks.
Built With
- alembic
- fastapi
- gitlab
- gitlab-ci/cd
- gitlab-duo-agent-platform
- gitlab-package-registry
- gitlab-releases
- postgresql
- psycopg
- python
- sqlalchemy
Log in or sign up for Devpost to join the conversation.