Webhook icon
Schedule icon
Log icon
Script icon
Docker icon
Queries icon
If icon
SlackIncomingWebhook icon

GitOps PostgreSQL Zero-Downtime Migration Verifier and Lock Risk Guard

Audit PostgreSQL DDL migrations for exclusive lock escalation and circular RLS, run transactional dry-runs, and post GitHub PR checks.

Categories
CloudDataInfrastructure

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.

Problem Statement

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:

  • ACCESS EXCLUSIVE Lock Escalation: Routine operations like altering a column type, adding a NOT NULL column without a default, or adding check constraints without 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.
  • Missing CONCURRENTLY on Indexes: Running 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.
  • Unindexed Foreign Keys: When foreign keys are added without supporting B-tree indexes on the child columns, deleting or updating primary key rows on the referenced parent table forces sequential scans and table-level locking on the child relation.
  • Circular RLS Recursion: In multi-tenant databases with Row-Level Security, defining policies that query back to parent or cross-tenant tables without SECURITY DEFINER wrappers leads to infinite recursion exceptions (ERROR: infinite recursion detected in policy for relation) and query deadlock.
  • Lack of Shift-Left Dry-Runs: Migrations often pass local unit tests with small synthetic datasets, only to fail in staging or production due to syntax mismatches, lock contention, or transaction timeouts.

Architecture and Workflow Mechanics

This workflow establishes an automated shift-left safety gate between GitHub pull requests and production deployment:

  1. Inbound Triggering:
    • Webhook Trigger (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.
    • Schedule Trigger (weekly_schema_drift_check): An optional scheduled sweep (0 3 * * 0, disabled by default) to re-verify staged migration batches or audit baseline drift.
  2. Event Logging (log_migration_event): Logs the incoming event context, including GitHub repository, pull request number, commit SHA, and targeted lock threshold.
  3. Static AST and Lock Analysis (audit_migration_ast): Executes a containerized Python static analyzer that evaluates every DDL statement against PostgreSQL lock hierarchy rules:
    • Detects column type changes (ALTER TABLE ... ALTER COLUMN ... TYPE) and unconstrained ADD COLUMN ... NOT NULL.
    • Enforces CONCURRENTLY on all CREATE INDEX and DROP INDEX statements.
    • Maps all declared foreign key constraints against migration indexes to flag unindexed foreign key hazards.
    • Audits Row-Level Security policies (CREATE POLICY) for un-encapsulated cross-table lookups causing circular recursion.
    • Calculates a normalized risk_score (0-100) and detects peak lock rank against max_allowed_lock_level.
  4. Transactional Dry-Run (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.
  5. Risk Gate Evaluation (evaluate_risk_gate): A flowable If task tests whether risk_score > 40 or has_breaking_changes is true:
    • High-Risk Path (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.
    • Safe Path (post_pr_review_approval & notify_migration_approved): Certifies the migration as production-ready and posts an approval status to Slack.
  6. Audit Record (record_migration_audit): Materializes the complete audit event record in the Kestra execution logs for compliance tracking.
  7. Error Handling (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.

PostgreSQL Lock Hierarchy Reference

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)

Prerequisites

  • A running PostgreSQL staging database instance with network access from Kestra workers.
  • A Slack workspace with an Incoming Webhook configured for database alerts.
  • Kestra instance with Docker runner enabled for containerized script execution.

Secret Configuration

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.

Input Parameters

  • 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.

Tasks Summary

  1. log_migration_event (io.kestra.plugin.core.log.Log): Logs incoming pull request and migration metadata.
  2. 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.
  3. transactional_dry_run (io.kestra.plugin.jdbc.postgresql.Queries): Executes the migration against staging PostgreSQL in an isolated transaction block with automatic ROLLBACK.
  4. 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.
  5. record_migration_audit (io.kestra.plugin.core.log.Log): Emits final audit compliance record with lineage.
  6. alert_pipeline_failure (io.kestra.plugin.notifications.slack.SlackIncomingWebhook): Global flow-level error listener.

Example Execution Output

Safe Migration (Approved)

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.

High-Risk Migration (Blocked)

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
}

GitHub Actions CI/CD Integration Runbook

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"
            }'

Quick Start

  1. Configure the three required secrets (STAGING_POSTGRES_URL, SLACK_WEBHOOK_URL, MIGRATION_WEBHOOK_KEY) in your Kestra namespace (company.team).
  2. Deploy this blueprint YAML file to your Kestra instance.
  3. Trigger a manual execution in the Kestra UI using the default inputs to test the happy path.
  4. Verify that the execution runs the static analyzer, performs the dry-run with rollback, and posts an approval certificate to Slack.
  5. Test a high-risk migration by passing ALTER TABLE users DROP COLUMN email; and verify that the gate blocks execution and dispatches an emergency Slack alert.

How to Extend

  • GitHub PR Status Checks: Add an 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.
  • Schema Diff Comparison: Integrate pg-dump or Atlas CLI to generate a visual ERD diff artifact comparing pre-migration and post-migration staging catalogs.
  • Lock Timeout Enforcement: Prepend SET lock_timeout = '2s'; to the migration payload in staging and production to guarantee statements fail fast instead of queuing up behind conflicting locks.
  • Liquibase / Flyway Integration: Connect to Flyway or Liquibase metadata tables (schema_version) to verify version number sequence ordering before executing DDL.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.