DEV Community

PhenoX
PhenoX

Posted on

Preventing Silent gRPC Schema Breakages in CI: Lessons Learned from Building an Automated Backward-Compatibility Checker

Preventing Silent gRPC Schema Breakages in CI: Lessons Learned from Building an Automated Backward-Compatibility Checker

In a microservices architecture where multiple cross-functional teams build and deploy services independently, gRPC and Protocol Buffers (Protobuf) serve as the backbone of inter-service communication. Protobuf is designed with schema evolution in mind, but subtle, implicit modifications to .proto definitions can silently compromise wire compatibility and bring down downstream consumers.

To eliminate human error during pull requests, we designed an automated CI validation pipeline to catch backward-incompatible schema changes before deployment. While the core validation logic and data models were technically sound, a fatal operational mismatch in our CI/CD container environment stalled our rollout.

Below is an in-depth breakdown of the technical requirements, the verification algorithm, the implementation post-mortem, and actionable engineering takeaways for building resilient CI verification tools.


1. Architectural Goals and Verification Requirements

Protobuf serialization relies on numerical field tags rather than field names. Modifying or reassigning these tags alters the wire representation, leading to corrupted deserialization or dropped fields.

flowchart TD
    subgraph CI ["CI Pipeline Architecture"]
        OldProto["Baseline Schema (Git Base)"]
        NewProto["Changed Schema (PR Head)"]
        Parser["AST / Schema Parser"]
        Validator["Compatibility Engine"]
        Verdict{"Is Compatible?"}
        Success["CI Pass (Exit 0)"]
        Failure["CI Failure (Exit 1)"]

        OldProto -- "Extract AST" --> Parser
        NewProto -- "Extract AST" --> Parser
        Parser -- "SchemaDefinition Models" --> Validator
        Validator -- "Generate Report" --> Verdict
        Verdict -- "No Breaking Changes" --> Success
        Verdict -- "Breaking Changes Detected" --> Failure
    end

To prevent schema regressions, we established five non-negotiable verification requirements:

  1. Strict Type Safety via Pydantic v2: Enforce structural validation over schema AST representations to reject malformed schema metadata before running comparison routines.
  2. High-Performance JSON Parsing via orjson: Enable rapid deserialization of complex schema definitions across hundreds of microservices without inflating CI runtimes.
  3. Comprehensive Breaking Change Detection:
    • Message Deletions: Detect missing top-level messages consumed by downstream clients.
    • Field Tag Deletions: Flag the removal of field numbers without explicit reserved status.
    • Type Mutations on Existing Tags: Prevent wire-incompatible mutations (e.g., mutating int64 to string on the same tag).
    • Field Renaming: Highlight mutations in field names that could break JSON/REST-transcoded payloads or client-side bindings.
    • Tag Collisions and Duplicate Assignment: Guard against overlapping field tags within the same message definition.

2. The QA Post-Mortem: A "Clean Container" Failure

The implementation achieved clean review approvals: the data models relied on Pydantic v2 validation, and dictionary lookups ensured an $O(N)$ differential analysis. However, during the QA gate within isolated test containers, execution terminated abruptly with a critical dependency error:

Execution Failure Log

[CRITICAL] 外部ライブラリ 'orjson' がインストールされていません。
以下のコマンドを実行して依存関係をインストールしてください:
  pip install orjson pydantic
Enter fullscreen mode Exit fullscreen mode

Root Cause Analysis

This breakdown was not caused by logical regressions in the verification algorithm. Instead, it was driven by an architectural mismatch between defensive programming assumptions and ephemeral CI environments.

sequenceDiagram
    autonumber
    actor Dev as "Developer / PR"
    participant Runner as "Ephemeral CI Container"
    participant Script as "Validator Script"

    Dev ->> Runner: "Trigger CI Pipeline"
    Note over Runner: "Clean Container spawned<br>(No cached packages)"
    Runner ->> Script: "Execute validate_schema.py"
    Script ->> Script: "import orjson (fails)"
    Script -->> Runner: "sys.exit(2) via Guard Clause"
    Note over Runner: "Pipeline Terminated: CI Blocked"
  1. Operational Handoff vs. Container Hermeticity: The development team documented prerequisites as operational instructions (pip install orjson pydantic). However, in hermetic CI/CD runners and unprivileged containers, manual steps are never executed unless explicitly defined in runner images or lockfiles.
  2. Defensive Guard Clauses Leading to Early Failure: The code implemented defensive import guards:
   try:
       import orjson
   except ImportError:
       print("[CRITICAL] 外部ライブラリ 'orjson' がインストールされていません。", file=sys.stderr)
       sys.exit(2)
Enter fullscreen mode Exit fullscreen mode

While defensive guard clauses prevent undefined execution when dependencies are missing, hard-failing without an automated in-memory fallback (e.g., the standard json module) turned an optimization dependency into a hard single point of failure (SPOF).


3. Implementation Code

Below is the complete implementation of the schema compatibility validator script that surfaced the environment mismatch during testing:

import sys
from typing import Dict, List, Set
from pydantic import BaseModel, Field, ValidationError

try:
    import orjson
except ImportError:
    print("[CRITICAL] 外部ライブラリ 'orjson' がインストールされていません。", file=sys.stderr)
    print("以下のコマンドを実行して依存関係をインストールしてください:", file=sys.stderr)
    print("  pip install orjson pydantic", file=sys.stderr)
    sys.exit(2)


# ==========================================
# Schema Models (Pydantic v2)
# ==========================================

class FieldDefinition(BaseModel):
    name: str = Field(..., description="Field name identifier")
    number: int = Field(..., description="Protobuf field tag number")
    type: str = Field(..., description="Field wire/data type")


class MessageDefinition(BaseModel):
    name: str = Field(..., description="Protobuf message identifier")
    fields: List[FieldDefinition] = Field(default_factory=list, description="Fields declared within the message")


class SchemaDefinition(BaseModel):
    messages: List[MessageDefinition] = Field(default_factory=list, description="List of messages declared in schema")


class CompatibilityReport(BaseModel):
    is_compatible: bool = Field(..., description="Flag indicating overall backward compatibility")
    errors: List[str] = Field(default_factory=list, description="List of detected backward-incompatible modifications")


# ==========================================
# Backward-Compatibility Validation Logic
# ==========================================

def validate_backward_compatibility(old_schema: SchemaDefinition, new_schema: SchemaDefinition) -> CompatibilityReport:
    errors: List[str] = []

    old_messages: Dict[str, MessageDefinition] = {m.name: m for m in old_schema.messages}
    new_messages: Dict[str, MessageDefinition] = {m.name: m for m in new_schema.messages}

    # Evaluate Message-Level Compatibility
    for msg_name, old_msg in old_messages.items():
        if msg_name not in new_messages:
            errors.append(f"Breaking Change: Message '{msg_name}' was removed.")
            continue

        new_msg = new_messages[msg_name]

        old_fields_by_num: Dict[int, FieldDefinition] = {f.number: f for f in old_msg.fields}
        new_fields_by_num: Dict[int, FieldDefinition] = {f.number: f for f in new_msg.fields}

        # Evaluate Field-Level Changes on Existing Tags
        for num, old_field in old_fields_by_num.items():
            if num not in new_fields_by_num:
                errors.append(
                    f"Breaking Change [{msg_name}]: Field tag {num} ('{old_field.name}') was deleted."
                )
                continue

            new_field = new_fields_by_num[num]

            if old_field.type != new_field.type:
                errors.append(
                    f"Breaking Change [{msg_name}]: Type changed on field tag {num} "
                    f"('{old_field.name}': {old_field.type} -> '{new_field.name}': {new_field.type})."
                )

            if old_field.name != new_field.name:
                errors.append(
                    f"Warning/Breaking Change [{msg_name}]: Field name changed on tag {num} "
                    f"('{old_field.name}' -> '{new_field.name}')."
                )

        # Detect Tag Collisions in the Target Schema
        seen_numbers: Set[int] = set()
        for new_field in new_msg.fields:
            if new_field.number in seen_numbers:
                errors.append(
                    f"Error [{msg_name}]: Duplicate field tag {new_field.number} detected."
                )
            seen_numbers.add(new_field.number)

    return CompatibilityReport(
        is_compatible=len(errors) == 0,
        errors=errors
    )


# ==========================================
# CI Entrypoint Execution
# ==========================================

def main() -> None:
    # Synthetic Schema Payloads Representing Input ASTs
    sample_old_schema_json = orjson.dumps({
        "messages": [
            {
                "name": "UserServiceRequest",
                "fields": [
                    {"name": "user_id", "number": 1, "type": "int64"},
                    {"name": "email", "number": 2, "type": "string"}
                ]
            }
        ]
    })

    sample_new_schema_json = orjson.dumps({
        "messages": [
            {
                "name": "UserServiceRequest",
                "fields": [
                    {"name": "user_id", "number": 1, "type": "string"},
                    {"name": "phone_number", "number": 3, "type": "string"}
                ]
            }
        ]
    })

    try:
        old_data = orjson.loads(sample_old_schema_json)
        new_data = orjson.loads(sample_new_schema_json)

        old_schema = SchemaDefinition.model_validate(old_data)
        new_schema = SchemaDefinition.model_validate(new_data)

    except (orjson.JSONDecodeError, ValidationError) as e:
        print(f"[ERROR] Failed to parse or validate schema data structure: {e}", file=sys.stderr)
        sys.exit(2)

    report = validate_backward_compatibility(old_schema, new_schema)

    if not report.is_compatible:
        print("==================================================", file=sys.stderr)
        print(" [CI BUILD FAILED] Breaking changes detected in gRPC schema!", file=sys.stderr)
        print("==================================================", file=sys.stderr)
        for err in report.errors:
            print(f"  - {err}", file=sys.stderr)
        print("==================================================", file=sys.stderr)
        sys.exit(1)
    else:
        print("[CI BUILD SUCCESS] gRPC backward compatibility preserved.")
        sys.exit(0)


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

4. Key Takeaways and Architectural Patterns for CI Tools

Building developer tooling and CI validation scripts requires a different set of priorities than building service runtimes. This failure highlighted two fundamental design principles:

1. Graceful Degradation and Standard Library Fallbacks

Performance libraries such as orjson provide tangible serialization speedups, but internal CI scripts must be resilient against missing binaries:

  • If a performance-oriented third-party dependency is missing, automatically fall back to Python's standard library (import json) rather than exiting immediately.
  • Treat external optimizations as non-essential enhancements when the workload can still be served safely by standard library modules.

> 💡 **For immediate deployment:** The complete source code suite (ZIP) for this architecture is available on [Gumroad](https://phenox.gumroad.com/l/jmwqtl) for $0+ (Pay What You Want).

python
try:
    import orjson as json_lib
except ImportError:
    import json as json_lib  # Safe, dependency-free fallback
Enter fullscreen mode Exit fullscreen mode

2. The Operational Fallacy in CI Automation

Relying on human-driven instructions or developer conventions ("run pip install before kicking the script") is an anti-pattern in automated software delivery:

  • Environment requirements must be strictly codified via explicit lockfiles (pyproject.toml, requirements.lock, or uv.lock).
  • Verification tools should either be distributed as zero-dependency scripts, pre-compiled standalone binaries, or baked directly into container base images used across all CI stages.

Static verification of Protobuf schemas is a powerful mechanism for guaranteeing microservice interface stability. Decoupling verification logic from environmental assumptions ensures that safety checks run reliably across every stage of the development lifecycle.


If this engineering log saved your production server (and your sanity), consider supporting our architecture on GitHub Sponsors.
Sponsor on GitHub

Top comments (0)