brdecide — the failover predictor

Goal

(Optional — see the brief for the shorter jq route and what skipping it costs you.) Build a tool that takes a failover-group status plus a clock and prints the decision the operator would take — the action, the alert, the Reason string, and whether cooldown will let it run. Check your own mental model against the real table now, on your terms, instead of at 3am while an incident checks it for you.

Unit 2 — How the operator decides · project · code-notebook · Python

Optional. This is a drill, not a dependency: nothing in Units 3–7 requires you to have built brdecide. What you give up by skipping it is the one thing reading cannot give you — being made to run the table on inputs you did not choose, including the four-site group, the writable reader that preempts TotalLoss, and the history record stamped inside the future-clock grace. If you skip it, at least read tests/fixtures/ and predict each verdict out loud before opening the harness’s expectations; that is the same exercise at a tenth of the cost.

Goal

Build a tool that takes a failover-group status plus a clock and prints the decision the operator would take — the action, the alert, the Reason string, and whether cooldown will let it run. Check your own mental model against the real table now, on your terms, instead of at 3am while an incident checks it for you.

Learning goals

How this works

You have run the cross-site table in your head. Now write it down, where a machine can check it.

brdecide reads a MysqlFailoverGroup object — the same JSON kubectl get mysqlfailovergroup playground -o json gives you — plus a clock reading, and prints what the operator would do with it. Nothing talks to a cluster. Every input is a fixture in tests/fixtures/, so the answers are reproducible and you can check yours against a known one.

python starter/brdecide.py --status tests/fixtures/playground-iad-down.json --now 2026-08-12T12:04:16Z
python starter/brdecide.py --status tests/fixtures/playground-iad-down-cooldown.json --now 2026-08-12T12:04:16Z --json

The starter runs as given. It prints Reason (unset) and coreCount 0, because the four functions that matter are empty.

The running example is playground: iad and pdx as primary-candidate, reader as role: read-only. Some fixtures add a fourth site to make a point.

Your tasks

TODO A — the pre-pass. In tally(), walk the observations once. Increment coreCount for every site whose role is not read-only — a dr-only site counts, an unknown state counts. Route any site that is writable while its role is not primary-candidate into fenceSites and skip it, so it never reaches a tally. Skip role: read-only sites entirely. Everything left lands in the tally for its state; unknown lands in none of them.

TODO B — the rows. In evaluate_cross_site(), evaluate the rows in the order the code does and return at the first one that fires: fence-first, TotalLoss, SplitBrain, Failover, NoPrimary, Degraded, Healthy. Exact conditions, alert strings and Reason values are in the docstring. Two of them are easy to get wrong: the failover row needs three conjuncts at once — zero writable and at least one unreachable and at least one read-only — and it is the only acting row that sets no alert.

TODO C — the history. In rehydrate_last_failover(), choose between the two durable copies of lastFailover: the status subresource and the bloodraven.shipstream.io/last-failover annotation. Discard either one stamped more than 5 minutes ahead of now, install the later of what survives, and give ties to status.

TODO D — the gate. In apply_gate(), build willRun. Fencing a writable non-promotable site runs every poll. A promotion is the one thing failoverCooldown gates. Report cooldownRemaining whenever there is a record to measure against, even when nothing is waiting on it.

Then run python3 tests/harness.py until it prints PASS.

What the scaffolding is for

Everything peripheral is wired: argument parsing, loading the object, the join of spec.sites[].role to status.sites[].state (a site absent from status is unknown, not missing), Go duration parsing, reading both history copies, rank_promotion_candidates(), and both output formats. The --json output is what the tests read, so keep its keys as new_action() defines them.

status.conditions is in the fixtures because it is in a real object. It is the previous poll’s verdict — sometimes stale by design. Do not read your answer out of it.

Expected output

brdecide — bloodraven-playground/playground at 2026-08-12T12:04:16Z

  sites         iad=unreachable(primary-candidate)  pdx=read-only(primary-candidate)  reader=read-only(read-only)
  coreCount     2
  tallies       writable=[] readOnly=['pdx'] unreachable=['iad']
  fenceSites    -

  Reason        Degraded
  Alert         (none)
  SplitBrain    no
  Candidates    pdx  (tiebreak order — GTID freshness picks the winner)

  lastFailover  2026-08-12T12:02:46Z → pdx  (from status)
  cooldown      5m0s configured, 3m30s remaining
  BLOCKED       promotion blocked by cooldown
  willRun       (nothing)

Rules

Steps

Grading

See rubric.md for the four weighted criteria and what full marks look like on each. The automated checks are the four testCases in project.json; they run the same functions tests/harness.py runs.

Steps

How it is graded

CriterionWhat earns itWeight
The rows are evaluated in the operator's order, with the fence-first return above everythingevaluate_cross_site() is a sequence of guarded early returns in the order fence-first, TotalLoss, SplitBrain, Failover, NoPrimary, Degraded, Healthy — not an unordered set of cases or a lookup keyed on a state tuple. Full marks require: the fence-first branch returning before the TotalLoss and SplitBrain rows can be reached; the failover row demanding zero writable, at least one unreachable and at least one read-only simultaneously; the failover row setting PromotionCandidates and Reason but no alert; both NoPrimary messages present with the two-site variant conditioned on exactly two read-only and zero unreachable; and every alert string reproduced verbatim, comma-space joins included. Deduct for any invented reason string outside the five the operator emits.35
The cooldown gates the promotion and nothing else, and the history it measures against is rehydrated correctlyapply_gate() blocks only promote, and only when a promotion was selected, a record exists, and the elapsed time is under the cooldown; fence:<site> entries are emitted regardless of the cooldown state. cooldownRemaining is reported whenever a record exists, including when nothing is waiting on it, and negative elapsed time counts as inside the window. rehydrate_last_failover() discards a copy stamped more than five minutes ahead of now, installs the later of what survives, and gives ties to status. The candidate list and Reason survive a blocked promotion unchanged — the table still ran.25
The pre-pass tallies sites by role the way the matrix doestally() increments coreCount for every non-read-only role, so a dr-only site counts toward coreCount and lands in a tally while a read-only site does neither. A writable non-primary-candidate site goes to fenceSites and is skipped before any tally sees it. unknown sites count toward coreCount and appear in no tally. Deduct if dr-only is treated as a reader, if a fenced site is double-counted in the writable tally, or if the promotion candidate list can contain a non-primary-candidate site.20
Craft: the decision is separated from its gate, and the output is readable under pressureevaluate_cross_site() takes only observations and priorities and touches no clock, history or cooldown, so the table can be reasoned about and tested on its own. The default text report is scannable at 3am: the reason, the alert and what will actually run are each on their own labelled line, and a blocked promotion is visibly distinct from a decision with nothing to do. A malformed or unreadable --status file produces a one-line diagnostic on stderr and a non-zero exit, not a traceback. Comments explain the non-obvious rules — why the failover row sets no alert, why the tie goes to status — rather than restating the code.20
Total100

Test cases

Test Checks Expected Weight
canonical_playground_decisions The eight canonical states of playground — healthy, primary up with a peer down, primary gone with a live replica, a writable reader, a writable reader alongside two writable candidates, split brain, all read-only, and total loss — each produce the right Reason, the verbatim alert string, the right tallies and the right willRun. Includes a fixture whose status.conditions still carries the previous poll's stale Healthy reason, so echoing the condition back fails here. PASS 40
awkward_topologies_and_history Generality on inputs the two-site mental model does not cover: a four-site group with a dr-only replica that counts toward coreCount but can never be promoted, a writable reader that preempts TotalLoss, an all-unknown startup, a sitePriorities list that reorders the tiebreak, a site absent from status.sites entirely, and history records that are skewed, tied, or stamped inside the future-clock grace. PASS 20
cooldown_gates_promotion_only Catches a shortcut: wrapping the whole decision in the cooldown instead of the single call it actually guards. A writable reader ten seconds into a 30 s cooldown must still emit fence:reader — fencing a non-promotable site is not gated. A promotion ninety seconds into a 5 m cooldown must still report its Reason and candidate list while withholding only promote. Also catches reading lastFailover from the status copy alone: one fixture's annotation is an hour newer, and the status copy on its own would let a blocked promotion run. PASS 25
table_stays_pure Structural: evaluate_cross_site(observations, site_priorities) must still exist with exactly that signature, its executable body must reference no clock, cooldown or failover history, and calling it directly must return Degraded with candidates ['pdx'] and no alert. apply_gate() must be where the cooldown lives. This is the real EvalCrossSite contract — the function is pure, and split-brain resolution and the anti-flap gate are layered on by the caller. PASS 15

Starter files

1 files

brdecide.py

                    #!/usr/bin/env python3
"""brdecide — predict the cross-site decision the Bloodraven operator would take.

Feed it a MysqlFailoverGroup object (``kubectl get mysqlfailovergroup playground -o json``)
and a clock. It prints the action, the alert, the ``Reason`` string that reaches
``status.conditions``, and whether ``spec.failoverCooldown`` will let the promotion run.

    python starter/brdecide.py --status tests/fixtures/playground-healthy.json
    python starter/brdecide.py --status tests/fixtures/playground-iad-down-cooldown.json \
        --now 2026-08-12T12:04:16Z --json

Everything peripheral is already wired: argument parsing, loading the object, joining
spec roles to status states, Go duration parsing, candidate ranking, and both output
formats. Four gaps are yours — TODO A, TODO B, TODO C, TODO D.
"""

from __future__ import annotations

import argparse
import json
import re
import sys
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone

# --- Grounded constants -----------------------------------------------------
# Site roles: api/v1alpha1/types.go — enum primary-candidate;dr-only;read-only.
ROLE_PRIMARY_CANDIDATE = "primary-candidate"
ROLE_DR_ONLY = "dr-only"
ROLE_READ_ONLY = "read-only"

# The four per-site states: internal/state/machine.go.
STATE_UNKNOWN = "unknown"
STATE_WRITABLE = "writable"
STATE_READ_ONLY = "read-only"
STATE_UNREACHABLE = "unreachable"

# spec.failoverCooldown default "5m"; the operator falls back to the same value
# when the pointer is nil.
DEFAULT_FAILOVER_COOLDOWN = 300.0

# FailoverClockSkewGrace = 5 * time.Minute.
FAILOVER_CLOCK_SKEW_GRACE = 300.0

# The two durable annotation keys, written as a pair by JSON merge patch.
LAST_FAILOVER_ANNOTATION = "bloodraven.shipstream.io/last-failover"
LAST_FAILOVER_TARGET_ANNOTATION = "bloodraven.shipstream.io/last-failover-target"


@dataclass(frozen=True)
class Observation:
    """One site at one poll cycle: its name, its configured role, its state."""

    name: str
    role: str
    state: str


def new_action() -> dict:
    """The empty CrossSiteAction. Fill these keys in evaluate_cross_site()."""
    return {
        "coreCount": 0,
        "writable": [],
        "readOnly": [],
        "unreachable": [],
        "fenceSites": [],
        "promotionCandidates": [],
        "splitBrain": False,
        "alert": None,
        "reason": "",
    }


# ===========================================================================
# TODO A — the pre-pass: coreCount, fence routing, the three tallies
# ===========================================================================
def tally(observations: list[Observation]) -> dict:
    """Walk the observations once and return the partial action.

    Set ``coreCount``, ``fenceSites``, ``writable``, ``readOnly`` and
    ``unreachable`` (name lists, in observation order). Leave the rest alone.

    Three rules, in this order, for every observation:

      1. ``coreCount`` increments for every site whose role is **not**
         ``read-only``. A ``dr-only`` site counts. An ``unknown`` state still
         counts — the site is part of the topology.
      2. A site that is ``writable`` while its role is **not**
         ``primary-candidate`` goes to ``fenceSites`` and is skipped: it never
         reaches a tally.
      3. A site whose role is ``read-only`` is skipped entirely.

    Everything that survives lands in the tally for its state. ``unknown``
    sites land in no tally at all.
    """
    action = new_action()
    # TODO A: implement the three rules above.
    return action


# ===========================================================================
# TODO B — the rows, in evaluation order
# ===========================================================================
def evaluate_cross_site(observations: list[Observation], site_priorities: list[str]) -> dict:
    """Return the CrossSiteAction for one poll cycle.

    This function is **pure**: no clock, no failover history, no cooldown. It
    mirrors EvalCrossSite in internal/state/matrix.go, which never considers
    history or policy beyond the supplied priorities.

    Evaluate the rows in this order and return at the first one that fires:

      1. fence-first — ``fenceSites`` non-empty:
         alert ``writable non-promotable site requires fencing (<sites>)``
         (comma-space joined), reason ``Degraded``.
      2. TotalLoss — ``len(unreachable) == coreCount``:
         alert ``TOTAL LOSS: all sites are unreachable``, reason ``TotalLoss``.
      3. SplitBrain — ``len(writable) > 1``: ``splitBrain`` True,
         alert ``SPLIT BRAIN: <n> sites are writable (<sites>)``,
         reason ``SplitBrain``.
      4. Failover — ``len(writable) == 0`` and ``len(unreachable) > 0`` and
         ``len(readOnly) > 0`` and ranking yields at least one candidate:
         set ``promotionCandidates``, reason ``Degraded``, **no alert**.
      5. NoPrimary — still no writable site: reason ``NoPrimary``, alert
         ``NO PRIMARY: both sites are read-only`` when exactly two read-only
         sites and zero unreachable, otherwise
         ``NO PRIMARY: no writable site available``.
      6. Degraded — exactly one writable and at least one unreachable:
         alert ``<unreachable sites> unreachable while <writable site> is primary``,
         reason ``Degraded``.
      7. Healthy — reason ``Healthy``, no alert.

    Use rank_promotion_candidates() for row 4; it is already written.
    """
    action = tally(observations)
    # TODO B: evaluate the rows above, in order, and return at the first hit.
    return action


# ===========================================================================
# TODO C — rehydrate the failover history from its two durable copies
# ===========================================================================
def rehydrate_last_failover(status_record, annotation_record, now):
    """Pick which durable copy of the failover history the operator believes.

    ``status_record`` and ``annotation_record`` are each ``(timestamp, target)``
    with ``timestamp`` a timezone-aware datetime or None.

    Return ``(timestamp, target, source)`` where ``source`` is ``"status"``,
    ``"annotation"`` or None.

      * Discard either copy stamped more than FAILOVER_CLOCK_SKEW_GRACE ahead of
        ``now``. A future-dated record would wedge promotion indefinitely.
      * Of what survives, install the **later** one.
      * Ties go to status: equal timestamps describe the same promotion.
      * Nothing left → ``(None, None, None)``.
    """
    # TODO C: implement the skew guard, the later-copy rule and the tie rule.
    return (None, None, None)


# ===========================================================================
# TODO D — the execution gate
# ===========================================================================
def apply_gate(action: dict, last_failover, now: datetime, cooldown: float) -> dict:
    """Decide what actually runs this poll, and report the cooldown timer.

    Return a dict with exactly these three keys:

      ``willRun``            list of strings, in order: one ``fence:<site>``
                             per entry in ``action["fenceSites"]``, then
                             ``promote`` when a promotion is selected and not
                             blocked.
      ``promotionBlockedBy`` ``"cooldown"`` or None.
      ``cooldownRemaining``  seconds left on the timer, as a float.

    The cooldown is enforced in exactly one place: immediately before the
    promotion call. So it can only ever block ``promote``. Fencing a writable
    non-promotable site is not gated by it — that runs every poll.

    Blocked when a promotion is selected, ``last_failover`` is set, and
    ``now - last_failover < cooldown``. Negative elapsed time (a record stamped
    in the future but inside the skew grace) counts as still active.

    ``cooldownRemaining`` is ``max(0.0, cooldown - elapsed)`` whenever
    ``last_failover`` is set — report the timer even when nothing is waiting on
    it — and ``0.0`` when it is not.
    """
    # TODO D: build willRun, set promotionBlockedBy, compute cooldownRemaining.
    return {"willRun": [], "promotionBlockedBy": None, "cooldownRemaining": 0.0}


# ===========================================================================
# Scaffolding below this line. You should not need to change any of it.
# ===========================================================================

_DURATION_RE = re.compile(r"(\d+(?:\.\d+)?)(ms|us|ns|h|m|s)")
_DURATION_UNITS = {
    "ns": 1e-9,
    "us": 1e-6,
    "ms": 1e-3,
    "s": 1.0,
    "m": 60.0,
    "h": 3600.0,
}


def parse_go_duration(text: str | None, default: float) -> float:
    """Parse a Go duration string such as "5m", "30s" or "1m30s" into seconds."""
    if not text:
        return default
    parts = _DURATION_RE.findall(text)
    if not parts:
        raise ValueError(f"cannot parse duration {text!r}")
    return sum(float(value) * _DURATION_UNITS[unit] for value, unit in parts)


def parse_rfc3339(text: str | None):
    """Parse an RFC3339 timestamp into a timezone-aware UTC datetime, or None."""
    if not text:
        return None
    cleaned = text.strip().replace("Z", "+00:00")
    parsed = datetime.fromisoformat(cleaned)
    if parsed.tzinfo is None:
        parsed = parsed.replace(tzinfo=timezone.utc)
    return parsed.astimezone(timezone.utc)


def format_rfc3339(moment) -> str | None:
    if moment is None:
        return None
    return moment.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def load_group(path: str) -> dict:
    with open(path, "r", encoding="utf-8") as handle:
        group = json.load(handle)
    if group.get("kind") != "MysqlFailoverGroup":
        raise ValueError(f"{path}: not a MysqlFailoverGroup object")
    return group


def observations_from(group: dict) -> list[Observation]:
    """Join spec.sites (role) to status.sites (state), in declared site order.

    A site the operator has not reported on yet is `unknown`, not missing.
    """
    states = {
        site.get("name"): site.get("state") or STATE_UNKNOWN
        for site in group.get("status", {}).get("sites", [])
    }
    observations = []
    for site in group.get("spec", {}).get("sites", []):
        name = site.get("name")
        if not name:
            raise ValueError("spec.sites[] entry with no name")
        observations.append(
            Observation(
                name=name,
                role=site.get("role") or ROLE_PRIMARY_CANDIDATE,
                state=states.get(name, STATE_UNKNOWN),
            )
        )
    if len(observations) < 2:
        raise ValueError("spec.sites has fewer than 2 entries")
    return observations


def read_history(group: dict):
    """Return the two durable copies as ((ts, target), (ts, target))."""
    status = group.get("status", {})
    annotations = group.get("metadata", {}).get("annotations", {}) or {}
    status_record = (
        parse_rfc3339(status.get("lastFailover")),
        status.get("lastFailoverTarget") or None,
    )
    annotation_record = (
        parse_rfc3339(annotations.get(LAST_FAILOVER_ANNOTATION)),
        annotations.get(LAST_FAILOVER_TARGET_ANNOTATION) or None,
    )
    return status_record, annotation_record


def rank_promotion_candidates(read_only: list[str], observations: list[Observation],
                              site_priorities: list[str]) -> list[str]:
    """Order the read-only primary-candidates: sitePriorities first, then declared order.

    Mirrors RankPromotionCandidates. Non-primary-candidate sites are dropped, so a
    `dr-only` replica is never returned. This list is the **tiebreaker** only:
    the operator ranks it by GTID freshness before promoting anything.
    """
    roles = {obs.name: obs.role for obs in observations}
    eligible = [name for name in read_only if roles.get(name) == ROLE_PRIMARY_CANDIDATE]
    out: list[str] = []
    for name in site_priorities or []:
        if name in eligible and name not in out:
            out.append(name)
    for name in eligible:
        if name not in out:
            out.append(name)
    return out


def decide(group: dict, now: datetime) -> dict:
    """Run the table, rehydrate the history, apply the gate. The whole tool."""
    observations = observations_from(group)
    spec = group.get("spec", {})
    site_priorities = (spec.get("splitBrainPolicy") or {}).get("sitePriorities") or []
    cooldown = parse_go_duration(spec.get("failoverCooldown"), DEFAULT_FAILOVER_COOLDOWN)

    action = evaluate_cross_site(observations, site_priorities)

    status_record, annotation_record = read_history(group)
    last_failover, last_target, source = rehydrate_last_failover(
        status_record, annotation_record, now
    )
    gate = apply_gate(action, last_failover, now, cooldown)

    meta = group.get("metadata", {})
    report = {
        "group": f"{meta.get('namespace', 'default')}/{meta.get('name', '?')}",
        "now": format_rfc3339(now),
        "sites": [{"name": o.name, "role": o.role, "state": o.state} for o in observations],
    }
    report.update(action)
    report.update(
        {
            "lastFailover": format_rfc3339(last_failover),
            "lastFailoverTarget": last_target,
            "lastFailoverSource": source,
            "cooldown": cooldown,
        }
    )
    report.update(gate)
    return report


def _human_seconds(seconds: float) -> str:
    seconds = float(seconds)
    if seconds >= 60:
        minutes, rest = divmod(seconds, 60)
        return f"{int(minutes)}m{rest:g}s"
    return f"{seconds:g}s"


def render_text(report: dict) -> str:
    lines = [f"brdecide — {report['group']} at {report['now']}", ""]
    sites = "  ".join(f"{s['name']}={s['state']}({s['role']})" for s in report["sites"])
    lines.append(f"  sites         {sites}")
    lines.append(f"  coreCount     {report['coreCount']}")
    lines.append(
        "  tallies       writable={} readOnly={} unreachable={}".format(
            report["writable"] or "[]", report["readOnly"] or "[]",
            report["unreachable"] or "[]",
        )
    )
    lines.append(f"  fenceSites    {report['fenceSites'] or '-'}")
    lines.append("")
    lines.append(f"  Reason        {report['reason'] or '(unset)'}")
    lines.append(f"  Alert         {report['alert'] or '(none)'}")
    lines.append(f"  SplitBrain    {'yes' if report['splitBrain'] else 'no'}")
    candidates = report["promotionCandidates"]
    if candidates:
        lines.append(
            "  Candidates    {}  (tiebreak order — GTID freshness picks the winner)".format(
                ", ".join(candidates)
            )
        )
    else:
        lines.append("  Candidates    -")
    lines.append("")
    if report["lastFailover"]:
        lines.append(
            "  lastFailover  {} → {}  (from {})".format(
                report["lastFailover"], report["lastFailoverTarget"] or "?",
                report["lastFailoverSource"],
            )
        )
        lines.append(
            "  cooldown      {} configured, {} remaining".format(
                _human_seconds(report["cooldown"]),
                _human_seconds(report["cooldownRemaining"]),
            )
        )
    else:
        lines.append("  lastFailover  (none recorded)")
        lines.append(f"  cooldown      {_human_seconds(report['cooldown'])} configured")
    if report["promotionBlockedBy"]:
        lines.append(f"  BLOCKED       promotion blocked by {report['promotionBlockedBy']}")
    lines.append(f"  willRun       {', '.join(report['willRun']) or '(nothing)'}")
    return "\n".join(lines)


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("--status", required=True,
                        help="path to a MysqlFailoverGroup JSON object")
    parser.add_argument("--now", default=None,
                        help="RFC3339 clock reading (default: the real clock)")
    parser.add_argument("--json", action="store_true",
                        help="emit the decision as JSON instead of a report")
    args = parser.parse_args(argv)

    try:
        group = load_group(args.status)
        now = parse_rfc3339(args.now) or datetime.now(timezone.utc)
        report = decide(group, now)
    except (OSError, ValueError, KeyError) as err:
        print(f"brdecide: {err}", file=sys.stderr)
        return 2

    print(json.dumps(report, indent=2) if args.json else render_text(report))
    return 0


if __name__ == "__main__":
    sys.exit(main())

                  

Projects are not auto-graded here. The rubric and the test cases above are the grading contract — run them yourself on your own machine.

Erase saved progress?

This erases all quiz scores, reading progress, project checklists, and your name on the certificate. It cannot be undone, and it affects only this course in this browser.