New to Kestra?
Use blueprints to kickstart your first workflows.
Audit PostgreSQL DDL migrations for exclusive lock escalation and circular RLS, run transactional dry-runs, and post GitHub PR checks.
Automate continuous verification and safety auditing for PostgreSQL database DDL migrations in GitOps pipelines. This blueprint statically analyzes SQL migrations for table-level exclusive locks, missing concurrent index operations, unindexed foreign keys, and circular Row-Level Security (RLS) recursion. It validates migration syntax and semantics via isolated transactional dry-runs against a staging PostgreSQL database and enforces a strict risk-scoring gate that posts real-time notifications to Slack and GitHub PRs.
Relational database migrations remain one of the most common causes of unplanned production downtime in cloud-native microservice architectures. While application services deploy seamlessly through zero-downtime rolling updates or blue-green deployments, database DDL operations can inadvertently lock entire tables:
NOT VALID acquire ACCESS EXCLUSIVE locks. In PostgreSQL, this blocks
all incoming queries (both reads and writes), rapidly exhausting connection pools and causing cascading HTTP 504 timeouts.CREATE INDEX or DROP INDEX without the CONCURRENTLY keyword locks the
table against writes (SHARE or ACCESS EXCLUSIVE lock) for the entire duration of index generation. On large tables,
this leads to hours of write starvation.SECURITY DEFINER wrappers leads to infinite recursion exceptions (ERROR: infinite recursion detected in policy for relation) and query deadlock.This workflow establishes an automated shift-left safety gate between GitHub pull requests and production deployment:
migration_pr_webhook): Triggered automatically by GitHub Actions whenever a pull request touching
SQL migration directories (db/migrations/** or migrations/*.sql) is opened, updated, or synchronized.weekly_schema_drift_check): An optional scheduled sweep (0 3 * * 0, disabled by default)
to re-verify staged migration batches or audit baseline drift.log_migration_event):
Logs the incoming event context, including GitHub repository, pull request number, commit SHA, and targeted lock threshold.audit_migration_ast):
Executes a containerized Python static analyzer that evaluates every DDL statement against PostgreSQL lock hierarchy rules:ALTER TABLE ... ALTER COLUMN ... TYPE) and unconstrained ADD COLUMN ... NOT NULL.CONCURRENTLY on all CREATE INDEX and DROP INDEX statements.CREATE POLICY) for un-encapsulated cross-table lookups causing circular recursion.risk_score (0-100) and detects peak lock rank against max_allowed_lock_level.transactional_dry_run):
Opens a JDBC connection to the staging PostgreSQL database via io.kestra.plugin.jdbc.postgresql.Queries and executes the
migration inside an isolated transaction block:BEGIN;
-- Migration DDL
ROLLBACK;
Because the transaction explicitly rolls back, staging database state remains completely untouched while verifying that
syntax, data types, constraints, and execution triggers succeed with zero runtime errors.evaluate_risk_gate):
A flowable If task tests whether risk_score > 40 or has_breaking_changes is true:alert_high_risk_migration): Dispatches an emergency alert to the database operations Slack channel
with full details of detected anti-patterns and safe alternative SQL rewrite suggestions.post_pr_review_approval & notify_migration_approved): Certifies the migration as production-ready
and posts an approval status to Slack.record_migration_audit):
Materializes the complete audit event record in the Kestra execution logs for compliance tracking.errors):
If the dry-run fails with a syntax error, staging connection timeout, or invalid credentials, the flow triggers alert_pipeline_failure
to notify on-call database administrators immediately.The static analysis engine evaluates DDL against the formal PostgreSQL lock mode matrix:
| Lock Level | Rank | Permissible Operations | Conflicting Modes |
|---|---|---|---|
| ACCESS_SHARE | 1 | SELECT | ACCESS_EXCLUSIVE |
| ROW_SHARE | 2 | SELECT FOR UPDATE / FOR SHARE | EXCLUSIVE, ACCESS_EXCLUSIVE |
| ROW_EXCLUSIVE | 3 | INSERT, UPDATE, DELETE | SHARE, SHARE_ROW_EXCLUSIVE, EXCLUSIVE, ACCESS_EXCLUSIVE |
| SHARE_UPDATE_EXCLUSIVE | 4 | VACUUM (non-full), ANALYZE, CREATE INDEX CONCURRENTLY, VALIDATE CONSTRAINT | SHARE_UPDATE_EXCLUSIVE, SHARE, SHARE_ROW_EXCLUSIVE, EXCLUSIVE, ACCESS_EXCLUSIVE |
| SHARE | 5 | CREATE INDEX (non-concurrent) | ROW_EXCLUSIVE, SHARE_UPDATE_EXCLUSIVE, SHARE_ROW_EXCLUSIVE, EXCLUSIVE, ACCESS_EXCLUSIVE |
| SHARE_ROW_EXCLUSIVE | 6 | CREATE TRIGGER, certain ALTER TABLE variants | ROW_EXCLUSIVE, SHARE_UPDATE_EXCLUSIVE, SHARE, SHARE_ROW_EXCLUSIVE, EXCLUSIVE, ACCESS_EXCLUSIVE |
| EXCLUSIVE | 7 | REFRESH MATERIALIZED VIEW CONCURRENTLY | ROW_SHARE, ROW_EXCLUSIVE, SHARE_UPDATE_EXCLUSIVE, SHARE, SHARE_ROW_EXCLUSIVE, EXCLUSIVE, ACCESS_EXCLUSIVE |
| ACCESS_EXCLUSIVE | 8 | DROP TABLE, TRUNCATE, ALTER TABLE ADD COLUMN, ALTER TABLE ALTER TYPE, VACUUM FULL | All lock modes (blocks all reads and writes) |
Configure the following secrets in your Kestra namespace (company.team):
STAGING_POSTGRES_URL: JDBC URL to staging PostgreSQL (e.g. jdbc:postgresql://postgres.staging.internal:5432/staging_db?user=kestra_ci&password=secretpassword).SLACK_WEBHOOK_URL: Slack Incoming Webhook URL for alerting.MIGRATION_WEBHOOK_KEY: Secret authentication token authorizing GitHub Actions to trigger the migration verification webhook.pr_number (INT, default: 101): GitHub Pull Request number being verified.repo_full_name (STRING, default: company/core-backend): Target GitHub repository in owner/repo format.commit_sha (STRING, default: HEAD): Commit SHA to annotate.migration_sql (STRING): SQL DDL migration payload to audit and dry-run.max_allowed_lock_level (SELECT, default: SHARE_UPDATE_EXCLUSIVE): Highest permissible PostgreSQL lock mode before triggering risk gate escalation.alert_channel (STRING, default: #database-ops): Slack channel destination for notifications.log_migration_event (io.kestra.plugin.core.log.Log): Logs incoming pull request and migration metadata.audit_migration_ast (io.kestra.plugin.scripts.python.Script): Analyzes SQL statements for lock escalation, missing CONCURRENTLY, unindexed foreign keys, and circular RLS recursion patterns.transactional_dry_run (io.kestra.plugin.jdbc.postgresql.Queries): Executes the migration against staging PostgreSQL in an isolated transaction block with automatic ROLLBACK.evaluate_risk_gate (io.kestra.plugin.core.flow.If): Tests if risk_score > 40 or has_breaking_changes is true:alert_high_risk_migration (io.kestra.plugin.notifications.slack.SlackIncomingWebhook): Posts emergency alert to Slack with anti-pattern details and safe rewrite suggestions.log_migration_blocked (io.kestra.plugin.core.log.Log): Records migration rejection in execution logs.post_pr_review_approval (io.kestra.plugin.core.log.Log): Logs production deployment approval certificate.notify_migration_approved (io.kestra.plugin.notifications.slack.SlackIncomingWebhook): Posts approval confirmation to Slack.record_migration_audit (io.kestra.plugin.core.log.Log): Emits final audit compliance record with lineage.alert_pipeline_failure (io.kestra.plugin.notifications.slack.SlackIncomingWebhook): Global flow-level error listener.When auditing zero-downtime migrations (such as adding a column with a constant default and creating an index concurrently):
{
"risk_score": 10,
"lock_level": "SHARE_UPDATE_EXCLUSIVE",
"detected_anti_patterns": [],
"has_breaking_changes": false,
"safe_rewrites": [],
"anti_pattern_count": 0,
"statements_analyzed": 2
}
Slack notification dispatched:
PASSED: PostgreSQL Migration Certified Safe for PR #101
Repository: company/core-backend
Commit SHA: HEAD
Risk Score: 10/100
Lock Mode: SHARE_UPDATE_EXCLUSIVE
Transactional Dry-Run: Succeeded with ROLLBACK
Status: Approved for production deploy.
When auditing migrations containing anti-patterns (such as retyping a column, non-concurrent index creation, unindexed foreign key, and circular RLS policy):
{
"risk_score": 100,
"lock_level": "ACCESS_EXCLUSIVE",
"detected_anti_patterns": [
{
"code": "COLUMN_RETYPE_ACCESS_EXCLUSIVE",
"statement": "ALTER TABLE orders ALTER COLUMN amount TYPE NUMERIC(18, 4)",
"severity": "CRITICAL",
"description": "Altering column type acquires ACCESS EXCLUSIVE lock and forces complete table rewrite.",
"remediation": "Add new column with target type, backfill asynchronously in batches, and switch application write paths."
},
{
"code": "INDEX_MISSING_CONCURRENTLY",
"statement": "CREATE INDEX idx_orders_customer ON orders(customer_id)",
"severity": "HIGH",
"description": "CREATE INDEX without CONCURRENTLY acquires SHARE lock, blocking concurrent INSERT, UPDATE, and DELETE operations.",
"remediation": "Use CREATE INDEX CONCURRENTLY IF NOT EXISTS to build index online without blocking production writes."
},
{
"code": "FOREIGN_KEY_UNINDEXED",
"statement": "ALTER TABLE order_items ADD CONSTRAINT fk_order FOREIGN KEY (order_id) REFERENCES orders(id)",
"severity": "HIGH",
"description": "Foreign key on order_items(order_id) references orders without supporting index, risking table-level locks during parent deletions and updates.",
"remediation": "Add supporting index: CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_order_items_order_id ON order_items(order_id);"
},
{
"code": "CIRCULAR_RLS_RECURSION_RISK",
"statement": "CREATE POLICY tenant_isolation_policy ON tenant_documents USING (tenant_id IN (SELECT id FROM accounts WHERE owner_id = current_user))",
"severity": "HIGH",
"description": "Policy tenant_isolation_policy on tenant_documents contains cross-table subquery without SECURITY DEFINER wrapper, risking circular RLS recursion.",
"remediation": "Wrap authorization lookup in a SECURITY DEFINER function with strict search_path, or use session variables (current_setting)."
},
{
"code": "LOCK_LEVEL_EXCEEDED",
"statement": "Global Migration Lock Evaluation",
"severity": "CRITICAL",
"description": "Maximum detected lock level ACCESS_EXCLUSIVE exceeds allowed threshold SHARE_UPDATE_EXCLUSIVE.",
"remediation": "Refactor migration DDL to acquire only SHARE_UPDATE_EXCLUSIVE or lower."
}
],
"has_breaking_changes": true,
"safe_rewrites": [
"Expand/contract: Add new typed column, backfill via batch UPDATEs, switch application dual-write.",
"Replace CREATE INDEX with CREATE INDEX CONCURRENTLY IF NOT EXISTS.",
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_order_items_order_id ON order_items(order_id);",
"Encapsulate policy subqueries on tenant_documents inside a SECURITY DEFINER function."
],
"anti_pattern_count": 5,
"statements_analyzed": 4
}
Add this GitHub Actions step to your pull request workflow (.github/workflows/db-migration-check.yml) to automatically trigger verification on every migration PR:
name: Database Migration Guard
on:
pull_request:
paths:
- 'migrations/**.sql'
- 'db/migrations/**.sql'
jobs:
verify-migration:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Concatenate Migration SQL Changes
id: extract_sql
run: |
MIGRATION_CONTENT=$(git diff origin/main...HEAD -- 'migrations/*.sql' | grep '^+[^+]' | sed 's/^+//' || cat migrations/*.sql)
echo "migration_payload<<EOF" >> $GITHUB_ENV
echo "$MIGRATION_CONTENT" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Trigger Kestra Migration Guard Webhook
run: |
curl -X POST "https://kestra.example.com/api/v1/executions/webhook/company.team/gitops-postgres-migration-guard/${{ secrets.MIGRATION_WEBHOOK_KEY }}" \
-H "Content-Type: application/json" \
-d '{
"pr_number": ${{ github.event.pull_request.number }},
"repo_full_name": "${{ github.repository }}",
"commit_sha": "${{ github.sha }}",
"migration_sql": ${{ toJson(env.migration_payload) }},
"max_allowed_lock_level": "SHARE_UPDATE_EXCLUSIVE",
"alert_channel": "#database-ops"
}'
STAGING_POSTGRES_URL, SLACK_WEBHOOK_URL, MIGRATION_WEBHOOK_KEY) in your Kestra namespace (company.team).ALTER TABLE users DROP COLUMN email; and verify that the gate blocks execution and dispatches an emergency Slack alert.io.kestra.plugin.core.http.Request task in the post_pr_review_approval block to set GitHub Commit Status Check (state: success) or leave an automated review comment.pg-dump or Atlas CLI to generate a visual ERD diff artifact comparing pre-migration and post-migration staging catalogs.SET lock_timeout = '2s'; to the migration payload in staging and production to guarantee statements fail fast instead of queuing up behind conflicting locks.schema_version) to verify version number sequence ordering before executing DDL.