How an Edge Case and Retry Storms Can Take Down a Database: Lessons from GitHub's Outage

Arpit Bhayani

Arpit Bhayani

May 23, 2022 • 8 min read

Play

In August 2021, GitHub experienced a major availability incident where one of its core MySQL primary instances entered a degraded state. Because the degraded instance was a primary (master) node responsible for handling write traffic, the downstream impact was immediate and widespread: multiple internal services stalled, and the user-facing github.com website experienced degraded performance and outages.

Upon post-incident investigation, GitHub revealed that the root cause traced back to an edge case in one of their most active services. This edge case generated poorly performing queries that saturated the database capacity, compounded by aggressive application retries and queueing logic.

This breakdown analyzes how a seemingly trivial edge case in a high-throughput service can escalate into a distributed system failure, illustrates the phenomenon through an architectural case study, and outlines defensive engineering patterns to prevent it.


Anatomy of the Incident: Degraded Primary and Cascading Failures

1. Primary Node Degradation

In a standard read-replica relational database topology, read operations are scaled across several replica nodes, while write operations are serialized through a designated primary node.

                     +-------------------+
                     | API / App Cluster |
                     +---------+---------+
                               |
          +--------------------+--------------------+
          | (Writes)                                | (Reads)
          v                                         v
+--------------------+                    +-------------------+
|   MySQL Primary    |--- Replication --->|   MySQL Replicas  |
| (Saturated / Down) |                    +-------------------+
+--------------------+

When read replicas become slow, traffic can often be shed, cached, or redirected. However, if a primary database enters a degraded state:

  • All writes halt or queue up indefinitely.
  • Distributed locks, session management, and state mutations time out.
  • Backpressure travels upstream into internal microservices, exhausting thread pools and connection pools across dependent services.

2. High-Frequency Invocation Multipliers

GitHub’s post-mortem highlighted that the bug resided in one of their most active applications.

In distributed systems, the operational risk of a bug is proportional to the traffic volume of the execution path:

  • An unoptimized query in a batch job running once every 24 hours consumes temporary I/O without degrading real-time user traffic.
  • The identical unoptimized query executed inside a critical hot path (e.g., rendering user profiles, parsing repositories, or loading permission trees) runs thousands of times per second, turning minor CPU/memory overhead into total resource exhaustion.

3. The Destructive Cycle of Application Retries

Transient database hiccups are often masked using retry logic. However, when an issue is systemic rather than transient (such as executing an intrinsically expensive query), standard retry logic transforms into a distributed denial-of-service (DDoS) attack against the database.

sequenceDiagram
    autonumber
    participant App as API Server
    participant DB as MySQL Primary

    App->>DB: Fire heavy query (Execution takes 10s)
    Note over App: Client timeout set to 5s
    DB-->>App: Still processing...
    Note over App: 5s timeout expires! Query aborted on client side.
    App->>DB: Retry #1 (Fires duplicate heavy query)
    Note over DB: MySQL continues working on Query 1 & Query 2 simultaneously!
    DB-->>DB: CPU and Buffer Pool saturated
    Note over App: 5s timeout expires again!
    App->>DB: Retry #2 (Fires another heavy query)
    Note over DB: Saturation leads to complete degradation

When the query duration (10s10\text{s}) exceeds the API timeout threshold (5s5\text{s}):

  1. The API server drops the connection and initiates a retry.
  2. The database continues processing the original abandoned query while receiving the retried query.
  3. The database receives double or triple the query volume while running at peak CPU, eliminating any idle capacity required to catch up or self-heal.

Fictional Case Study: The Zero-Value Date Bug

While GitHub did not disclose the proprietary application code, we can model this failure mode using a representative, real-world scenario: aggregating metrics from an append-only ledger.

Scenario Setup

Consider a commit history table that tracks code contributions:

CREATE TABLE commits (
    id BIGINT PRIMARY KEY,
    author_id BIGINT NOT NULL,
    message TEXT NOT NULL,
    created_at BIGINT NOT NULL, -- Epoch milliseconds
    INDEX idx_author_created (author_id, created_at)
);

Imagine an endpoint serving user profile cards on GitHub. The card displays basic profile details alongside an activity metric: “72 commits in the last 7 days”.

To decouple client views (mobile vs. desktop), the client provides a start_at timestamp parameter:

GET /api/commits?author_id=123&start_at=1629700000000

The intended query uses an index range scan:

SELECT COUNT(id) 
FROM commits 
WHERE author_id = 123 
  AND created_at > 1629700000000;

Because the query traverses only the records created in the last week for that specific author_id, the database touches a tiny subset of rows in memory.

The Silent Type-Coercion Failure

Suppose the API service is written in Go (or any statically typed language with default zero-values). The handler extracts the query string parameter and parses it:

// Vulnerable Handler Implementation
func GetRecentCommitCount(w http.ResponseWriter, r *http.Request) {
    authorID := r.URL.Query().Get("author_id")
    startAtParam := r.URL.Query().Get("start_at")

    // Developer assumes start_at is always supplied and valid
    startDate, _ := strconv.ParseInt(startAtParam, 10, 64)

    query := fmt.Sprintf(
        "SELECT COUNT(id) FROM commits WHERE author_id = %s AND created_at > %d;",
        authorID,
        startDate,
    )

    // Execute against primary or read replica...
}

What Happens in the Edge Case?

  1. A front-end release introduces a subtle bug: a calendar widget regression passes start_at="" or omits it entirely.
  2. strconv.ParseInt encounters an empty string or malformed input and returns an error: ErrSyntax.
  3. Because the error is ignored via the blank identifier (_), Go sets startDate to its zero-value: 0.
  4. The resulting query sent to the database engine becomes:
SELECT COUNT(id) 
FROM commits 
WHERE author_id = 123 
  AND created_at > 0;

The Impact on the Database Engine

  • created_at > 0 matches every single commit ever created by that user since account inception.
  • If the user is an active open-source contributor with hundreds of thousands of lifetime commits across thousands of repositories, this switches the execution plan from a rapid index seek to a deep, expensive index scan.
  • Multiplied by millions of requests to profile cards, the database primary’s buffer pool is swept clean of hot cached pages, disk I/O spikes to 100%, and CPU usage hits saturation levels.

Mitigation and Defensive Engineering Patterns

Preventing catastrophic failures of this nature requires defense-in-depth across the application and database tiers.

1. Never Suppress Errors or Rely on Default Zero-Values

Input processing must fail closed. If an input fails validation or deserialization, reject the request at the boundary with an HTTP 400 Bad Request rather than proceeding with synthetic or default values.

startDate, err := strconv.ParseInt(startAtParam, 10, 64)
if err != nil || startDate <= 0 {
    http.Error(w, "Invalid or missing 'start_at' timestamp parameter", http.StatusBadRequest)
    return
}

2. Apply Defensive SQL Guardrails (Bounded Limits)

Never assume that a client-driven filter will adequately protect the database engine. Enforce hard limits and bounded windows directly in query construction:

-- Ensure a minimum bounded range even if inputs are malformed
SELECT COUNT(id) 
FROM commits 
WHERE author_id = 123 
  AND created_at > GREATEST(?, UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY) * 1000)
LIMIT 1000;

Even if a metric becomes slightly approximate (e.g., displaying 1000+ commits instead of an uncapped scan), protecting the primary database from an uncontrolled full index scan preserves cluster-wide availability.

3. Circuit Breakers and Adaptive Backoff Over Blind Retries

Naïve retries turn slow queries into full outages. High-scale systems should enforce:

  • Exponential Backoff with Full Jitter: Prevents synchronized thundering herds.
  • Retry Budgets: Limit retries to a fixed percentage (e.g., at most 10% of total incoming requests).
  • Cancel Contexts Upstream: Use database drivers that propagate client cancellations down to the engine level (e.g., KILL QUERY in MySQL or context cancellation in Go) so that orphaned queries do not continue consuming resources.

4. Query Timeouts at the Database Level

Do not rely solely on client-side timeouts. A client-side timeout closes the socket, but the database might continue running the query until completion. Configure query-level execution limits on the database itself:

-- In MySQL 5.7.8+
SELECT /*+ MAX_EXECUTION_TIME(2000) */ COUNT(id) 
FROM commits 
WHERE author_id = 123 AND created_at > ?;

This instructs the database engine to terminate query execution automatically if it exceeds the allocated threshold (e.g., 2000 milliseconds), preserving primary node capacity.


Summary

The August 2021 GitHub degraded state serves as an important case study in distributed systems resilience: at scale, the distinction between business logic edge cases and infrastructure outages disappears.

A missing input validation check combined with default zero-values can transform an optimized index seek into an exhaustive scan. When deployed onto a hot execution path and reinforced by aggressive retry loops, a minor code-level oversight can rapidly degrade an entire primary database.

Arpit Bhayani

Principal Engineer II at Razorpay - building Agent Studio, Ex-staff engg at GCP Memorystore & Dataproc, Creator of DiceDB, ex-Amazon Fast Data, ex-Director of Engg. SRE and Data Engineering at Unacademy. I spark engineering curiosity through my no-fluff engineering videos on YouTube and my courses