Guide

Automated Baseline Promotion and Rollback

Automated baseline promotion and rollback is the lifecycle controller that decides when a newly observed query plan earns the role of anchored baseline and when a live baseline must be reverted to its predecessor. This stage consumes verdict history and stability signals, enforces a probation window with exact promotion and rollback criteria, and performs an atomic, idempotent version swap that emits a durable baseline version transition record. It never re-plans a query, never captures raw EXPLAIN output, and never gates a deploy — its sole responsibility is moving the anchor forward under promotion rules and backward under rollback rules, reproducibly.

This controller sits at the tail of the Regression Detection & Rule Engines subsystem. Everything upstream — delta computation, threshold classification, structural detectors — produces a stream of verdicts about a candidate plan. Those verdicts are worthless unless something eventually decides that a candidate has proven itself and should replace the reference the rest of the pipeline diffs against. That decision, and its inverse, is what this stage owns. A promotion that fires too early re-anchors the pipeline on a regression; a rollback that fires too late leaves a degraded plan as the yardstick every future comparison is measured against.

Architectural Boundaries

The promotion controller is a state machine over a single per-fingerprint row: (current_version, candidate_version, probation_state). It reads two upstream inputs and writes exactly one downstream artifact. The inputs are the append-only verdict history produced by the threshold-tuning stage and a stability signal bundle — probation execution count, elapsed probation duration, and the observed p95 latency ratio against the incumbent. Both inputs are read-only from the controller’s perspective; it never mutates a verdict or backfills a stability sample.

Every candidate is identified by the deterministic fingerprint from the plan hashing algorithm, so promotion is always scoped to one logical query. The cost signal the controller trusts is the anchored weighted delta produced by tracking cost deltas across baseline versions: a candidate is only promotable while its cost delta against the incumbent stays at or below 1.1×1.1\times. The controller’s single output is a BaselineVersionTransition record — an immutable row naming the from-version, the to-version, the trigger (PROMOTE or ROLLBACK), and the evidence snapshot that justified the swap. That record is the contract every downstream consumer reads; the CI gate, the alert router, and the audit ledger all key off it.

Baseline promotion controller boundariesVerdict history and stability signals enter an ordered promotion gate that checks probation count, elapsed window, and cost delta, performs an atomic monotonic swap, and emits a BaselineVersionTransition record; a dashed coral path reverts to the prior pinned version on a p95 rollback trip.Verdict Historyappend-only · per fingerprintStability Signalscount · window · p95 ratioPromotion Controllerordered gate · atomic swap1 · Probation GateN ≥ 200 over ≥ 24 h2 · Cost-Delta Gatedelta ≤ 1.1× incumbent3 · Atomic Version Swapmonotonic key · single txntransitionVersion Transitionfrom → to · trigger · evidenceaudit ledger anchorp95 > 2× → rollbackRevert to prior pinned version
Verdict history and stability signals enter an ordered promotion gate; a candidate that clears the probation, cost-delta, and swap steps becomes the anchored baseline, while a p95 breach above 2× drives an atomic revert to the prior pinned version.

Two invariants keep this boundary safe. The version key is strictly monotonic per fingerprint, so a swap can never move the anchor backward except through an explicit ROLLBACK transition that itself records a new, higher key pointing at the older plan body. And the transition write and its audit event share one transaction, so there is no observable window in which the anchor has moved but the ledger has not.

Deterministic Routing and Schema Enforcement

A promotion decision is a pure function of the candidate’s evidence snapshot plus the incumbent row it locks. The controller rejects any evidence bundle that fails schema validation rather than promoting on partial data, because a promotion driven by a truncated stability signal is indistinguishable from a promotion that skipped the gate entirely.

The evidence snapshot attached to every transition is validated against this JSON Schema before the swap transaction opens:

JSON
{
  "$id": "https://queryplan.org/schemas/baseline-version-transition.json",
  "type": "object",
  "additionalProperties": false,
  "required": ["query_fingerprint", "from_version", "to_version", "trigger", "probation_executions", "probation_seconds", "cost_delta_ratio", "p95_ratio"],
  "properties": {
    "query_fingerprint":     { "type": "string", "pattern": "^[a-f0-9]{64}$" },
    "from_version":          { "type": "integer", "minimum": 0 },
    "to_version":            { "type": "integer", "exclusiveMinimum": 0 },
    "trigger":               { "type": "string", "enum": ["PROMOTE", "ROLLBACK"] },
    "probation_executions":  { "type": "integer", "minimum": 0 },
    "probation_seconds":     { "type": "integer", "minimum": 0 },
    "cost_delta_ratio":      { "type": "number", "exclusiveMinimum": 0 },
    "p95_ratio":             { "type": "number", "exclusiveMinimum": 0 }
  }
}

The version key is derived, never chosen by the caller. Each fingerprint owns an independent monotonic sequence, and the next key is computed from the incumbent under a row lock:

to_version = current_version + 1 (asserted CHECK to_version > from_version)

Because the sequence is per-fingerprint and advanced only inside the locked swap transaction, two controllers racing on the same query cannot both mint to_version; the loser’s INSERT collides on the (query_fingerprint, to_version) unique constraint and its whole transaction rolls back with the anchor untouched. Routing of a decision is equally deterministic — the controller emits exactly one of a fixed trigger set (PROMOTE, ROLLBACK, HOLD), and only the first two produce a transition row. A HOLD is a no-op that advances no version and writes no ledger entry, so replaying the same evidence bundle is always safe.

Production-Ready Implementation

The controller below is an asyncio service. It performs the promotion inside a single asyncpg transaction with SELECT ... FOR UPDATE on the incumbent row, writes the transition and its audit event atomically, and is idempotent under an idempotency_key. Every decision is traced with OpenTelemetry spans and metrics and logged through structlog. The rollback path shares the same swap primitive, differing only in which plan body the new version points at.

PYTHON
from __future__ import annotations

import os
from dataclasses import dataclass
from typing import Literal

import asyncpg
import structlog
from opentelemetry import metrics, trace

log = structlog.get_logger(__name__)
tracer = trace.get_tracer(__name__)
meter = metrics.get_meter(__name__)

PROMOTION_TOTAL = meter.create_counter("baseline_promotion_total", unit="1")
ROLLBACK_TOTAL = meter.create_counter("baseline_rollback_total", unit="1")
SWAP_DURATION = meter.create_histogram("baseline_swap_duration_ms", unit="ms")

# --- Exact promotion / rollback thresholds (mirror promotion.yaml) --------
MIN_PROBATION_EXECS = 200
MIN_PROBATION_SECONDS = 86_400          # 24 h canary window
MAX_COST_DELTA_RATIO = 1.1              # candidate cost ≤ 1.1× incumbent
ROLLBACK_P95_RATIO = 2.0               # revert if p95 > 2× within window

Trigger = Literal["PROMOTE", "ROLLBACK", "HOLD"]


@dataclass(frozen=True)
class CandidateEvidence:
    query_fingerprint: str
    candidate_plan_id: int
    probation_executions: int
    probation_seconds: int
    cost_delta_ratio: float
    p95_ratio: float
    idempotency_key: str


def decide(ev: CandidateEvidence, has_incumbent: bool) -> Trigger:
    """Pure routing over the evidence bundle — no I/O, fully replayable."""
    if has_incumbent and ev.p95_ratio > ROLLBACK_P95_RATIO:
        return "ROLLBACK"
    if (
        ev.probation_executions >= MIN_PROBATION_EXECS
        and ev.probation_seconds >= MIN_PROBATION_SECONDS
        and ev.cost_delta_ratio <= MAX_COST_DELTA_RATIO
    ):
        return "PROMOTE"
    return "HOLD"


class PromotionController:
    """Atomic, idempotent baseline anchor transitions over asyncpg."""

    def __init__(self, pool: asyncpg.Pool) -> None:
        self._pool = pool

    async def run(self, ev: CandidateEvidence) -> Trigger:
        with tracer.start_as_current_span("baseline.promotion") as span:
            span.set_attribute("query_fingerprint", ev.query_fingerprint)
            async with self._pool.acquire() as conn:
                async with conn.transaction():
                    incumbent = await conn.fetchrow(
                        """
                        SELECT current_version, plan_id
                        FROM baseline_anchor
                        WHERE query_fingerprint = $1
                        FOR UPDATE
                        """,
                        ev.query_fingerprint,
                    )
                    trigger = decide(ev, incumbent is not None)
                    span.set_attribute("trigger", trigger)

                    if trigger == "HOLD":
                        log.info("promotion_hold",
                                 fingerprint=ev.query_fingerprint,
                                 execs=ev.probation_executions,
                                 seconds=ev.probation_seconds)
                        return "HOLD"

                    from_version = incumbent["current_version"] if incumbent else 0
                    to_version = from_version + 1
                    target_plan = (
                        incumbent["plan_id"] if trigger == "ROLLBACK"
                        else ev.candidate_plan_id
                    )
                    # For ROLLBACK the target is the *prior* pinned plan, resolved
                    # from the ledger rather than the live incumbent.
                    if trigger == "ROLLBACK":
                        prior = await conn.fetchval(
                            """
                            SELECT plan_id FROM baseline_version_transition
                            WHERE query_fingerprint = $1 AND trigger = 'PROMOTE'
                            ORDER BY to_version DESC OFFSET 1 LIMIT 1
                            """,
                            ev.query_fingerprint,
                        )
                        if prior is None:
                            raise RuntimeError("no prior baseline to roll back to")
                        target_plan = prior

                    # Idempotent transition insert; a replay collides and no-ops.
                    inserted = await conn.fetchval(
                        """
                        INSERT INTO baseline_version_transition
                            (query_fingerprint, from_version, to_version, trigger,
                             plan_id, cost_delta_ratio, p95_ratio,
                             probation_executions, probation_seconds,
                             idempotency_key, created_at)
                        VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10, now())
                        ON CONFLICT (idempotency_key) DO NOTHING
                        RETURNING to_version
                        """,
                        ev.query_fingerprint, from_version, to_version, trigger,
                        target_plan, ev.cost_delta_ratio, ev.p95_ratio,
                        ev.probation_executions, ev.probation_seconds,
                        ev.idempotency_key,
                    )
                    if inserted is None:
                        log.info("promotion_replay_noop",
                                 fingerprint=ev.query_fingerprint,
                                 idempotency_key=ev.idempotency_key)
                        return trigger

                    # Move the anchor and write the audit event in the same txn.
                    await conn.execute(
                        """
                        INSERT INTO baseline_anchor
                            (query_fingerprint, current_version, plan_id, updated_at)
                        VALUES ($1, $2, $3, now())
                        ON CONFLICT (query_fingerprint)
                        DO UPDATE SET current_version = EXCLUDED.current_version,
                                      plan_id = EXCLUDED.plan_id,
                                      updated_at = now()
                        """,
                        ev.query_fingerprint, to_version, target_plan,
                    )
                    await conn.execute(
                        """
                        INSERT INTO baseline_audit_event
                            (query_fingerprint, from_version, to_version,
                             trigger, actor, created_at)
                        VALUES ($1,$2,$3,$4,'promotion-controller', now())
                        """,
                        ev.query_fingerprint, from_version, to_version, trigger,
                    )

            if trigger == "PROMOTE":
                PROMOTION_TOTAL.add(1, {"result": "committed"})
            else:
                ROLLBACK_TOTAL.add(1, {"result": "committed"})
            log.info("baseline_transition",
                     fingerprint=ev.query_fingerprint,
                     from_version=from_version, to_version=to_version,
                     trigger=trigger)
            return trigger


async def build_pool() -> asyncpg.Pool:
    return await asyncpg.create_pool(
        dsn=os.environ["PROMOTION_DSN"],
        min_size=2,
        max_size=int(os.environ.get("PROMOTION_POOL_MAX", "8")),
        command_timeout=5.0,
    )

Three properties make this production-safe. The FOR UPDATE on baseline_anchor serializes every transition for a fingerprint, so a concurrent promoter blocks until the first commits and then re-reads the advanced incumbent. The idempotency_key conflict clause means a retried decision — a redelivered queue message, a re-run job — commits at most one transition, so at-least-once delivery never double-advances the version. And the anchor move plus audit event live in one transaction with the transition insert, so the ledger can never disagree with the live anchor.

Threshold Reference

The controller carries two distinct sets of numbers: the decision thresholds that drive PROMOTE/ROLLBACK, and the operational SLOs alerting is wired against. Both are exact and neither is workload-tunable at runtime without a deliberate config change and a drain.

SignalMetricPromote / PassWarnRollback / Block
Probation executionsprobation_executions≥ 200120–199< 120 (never promote)
Probation durationprobation_seconds≥ 86 400 s (24 h)43 200–86 399 s< 43 200 s
Candidate cost ratiocost_delta_ratio≤ 1.10×1.10–1.25×> 1.25×
In-window p95 ratiop95_ratio≤ 1.5×1.5–2.0×> 2.0× (rollback)
Swap latencybaseline_swap_duration_ms p95≤ 40 ms40–120 ms> 120 ms
Rollback ratebaseline_rollback_total / promotions< 2 %2–6 %> 6 %

A rollback rate sustained above 6 % is the primary calibration alarm: it almost always means the probation window is too short for the workload’s daily cycle, not that plans genuinely regress after promotion. Encode both the safety trip and the calibration alarm as Prometheus rules:

YAML
groups:
  - name: baseline-promotion-controller
    rules:
      - alert: BaselineRollbackStorm
        expr: |
          (
            sum(rate(baseline_rollback_total[30m]))
            /
            clamp_min(sum(rate(baseline_promotion_total[30m])), 1)
          ) > 0.06
        for: 15m
        labels:
          severity: page
        annotations:
          summary: "Rollback rate above 6% for 15m — probation window likely too short"
          runbook: "Raise min_probation_seconds toward 172800 (48 h) before re-promoting."

      - alert: BaselineSwapLatencyHigh
        expr: |
          histogram_quantile(0.95, sum(rate(baseline_swap_duration_ms_bucket[10m])) by (le)) > 120
        for: 10m
        labels:
          severity: ticket
        annotations:
          summary: "Version-swap p95 > 120ms — check anchor-row lock contention"

Failure Scenarios and Root Cause Analysis

1. Promotion never fires for a clearly stable candidate. Symptom: probation_executions climbs well past 200 but the trigger stays HOLD indefinitely. Root cause: the probation counter and the probation clock disagree — executions accrued in bursts, so probation_seconds never crossed 86 400 despite the count clearing 200. Diagnose the two gates independently:

SQL
SELECT query_fingerprint, probation_executions, probation_seconds,
       (probation_executions >= 200) AS exec_ok,
       (probation_seconds >= 86400)  AS window_ok
FROM candidate_probation
WHERE query_fingerprint = $1;

Mitigation: both gates are AND-ed by design. If a batch job legitimately accrues 200 executions in an hour, lower min_probation_seconds for that workload class deliberately — never special-case it in code.

2. Premature promotion on retry-inflated counts. Symptom: a candidate promotes in under an hour, then rolls back within the day. Root cause: application-level retries counted the same logical execution multiple times, inflating probation_executions past 200 without real independent samples. Diagnose by comparing distinct trace IDs to raw execution count:

BASH
jq -r 'select(.query_fingerprint=="'$FP'") | .trace_id' probation.jsonl | sort -u | wc -l

Mitigation: dedupe probation samples on trace_id at the producer, so retries of one request contribute a single execution.

3. Flapping baseline between two plan bodies. Symptom: the same fingerprint promotes plan A, rolls back to plan B, re-promotes A, on a tight cycle. Root cause: no hysteresis — a candidate at exactly the 1.1×1.1\times boundary oscillates across it. Diagnose by inspecting recent transitions:

SQL
SELECT to_version, trigger, cost_delta_ratio, created_at
FROM baseline_version_transition
WHERE query_fingerprint = $1
ORDER BY to_version DESC LIMIT 8;

Mitigation: require a candidate to clear a cooldown of at least 6 h after any rollback before it is eligible to promote again; the cooldown lives in the eligibility feed, not the pure decide function.

4. Split-brain double promotion. Symptom: two to_version values appear for one fingerprint at the same wall-clock second, or a unique-constraint violation floods the logs. Root cause: two controller replicas processed the same candidate and both attempted the swap. This is expected and safe — the FOR UPDATE lock plus the (query_fingerprint, to_version) unique constraint force one to abort. Diagnose the loser rate with the database’s deadlock and serialization-failure counters. Mitigation: none required in code; confirm the losing transaction rolls back cleanly and never partially applies.

5. Audit gap after a crash. Symptom: baseline_anchor.current_version advanced but no matching baseline_audit_event row exists. Root cause: the audit insert was moved outside the swap transaction during a refactor, breaking atomicity. Diagnose with an anti-join:

SQL
SELECT a.query_fingerprint, a.current_version
FROM baseline_anchor a
LEFT JOIN baseline_audit_event e
  ON e.query_fingerprint = a.query_fingerprint
 AND e.to_version = a.current_version
WHERE e.query_fingerprint IS NULL;

Mitigation: the anchor move, transition insert, and audit event must share one conn.transaction() block — never emit the audit event on a separate connection or after commit.

Configuration Reference

Key / Env VarDefaultPurpose
min_probation_execs200Independent executions a candidate must accrue before promotion.
min_probation_seconds86400Minimum canary window length in seconds (24 h).
max_cost_delta_ratio1.1Candidate cost ceiling relative to the incumbent for promotion.
rollback_p95_ratio2.0In-window p95 latency ratio that forces an immediate rollback.
rollback_cooldown_seconds21600Post-rollback quiet period (6 h) before re-eligibility, adds hysteresis.
PROMOTION_DSNConnection string for the anchor and transition store.
PROMOTION_POOL_MAX8Upper bound on the asyncpg pool; raise under swap-lock contention.
PROMOTION_SHARD_IDController replica identity, recorded on audit events for forensics.

Raise min_probation_seconds and rollback_cooldown_seconds first when chasing flapping or premature promotion; loosen max_cost_delta_ratio only with explicit sign-off, since it directly governs what plan the entire pipeline diffs against. Change one variable per rollout so the calibration stays reproducible against replayed evidence.

← Back to Regression Detection & Rule Engines