Späť na blog

Polia zmizli ale nič nespadlo: Zachyť Schema Evolution bugy pred produkciou

|
| protobuf, avro, schema, testing, ci, data-loss

Schema evolution bugy ma naučili, že “úspešne kompilovalo” neznamená nič keď tvoj producer a consumer hovoria rôzne verzie schémy. “Kde sa podelo pole email? Eventy stále prídu.” Debugovali sme produkčný incident kde user emaily zrazu prestali prichádzať do analytického pipelinu. Žiadne errory v logoch. Kafka consumer lag vyzeral normálne. Správy sa spracovávali v bežnom throughpute. Ale pole email v našich eventoch bolo vždy prázdne.

Časová os bola krupobití. Štvrtok 15:00: backend tím deployol upgrade služby ktorý “vyčistil nepoužívané Protobuf polia.” Štvrtok 15:05: analytický pipeline začal dostávať eventy s chýbajúcimi email dátami. Piatok 9:00: business tím si všimol že email campaign metriky sú všetky nuly. Piatok 14:00: nakoniec sme spojili bodky—backend odstránili pole ktoré si mysleli že je nepoužívané, ale analytický consumer ho stále čítal na starej schéme.

Čo robilo toto zvlášť zákerným bolo že všetko vyzeralo zdravo. Protobuf deserializácia nezlyhala—len ticho ignorovala chýbajúce field mapping. Consumer spracovával eventy úspešne, zapisoval do databázy úspešne a nereportoval žiadne errory. Mali sme komplexné unit testy, integration testy a dokonca load testy. Ale nikdy sme netestovali schema kompatibilitu naprieč verziami.

Toto je typ bugu čo ťa prinúti spochybniť hodnotu celého testovacieho suite. Schema evolution je v skutočnosti API kontrakt medzi producerom a consumerom, ale na rozdiel od REST API s explicitným verzovaním a OpenAPI schémami, message schémy sa menia neviditeľne. Jeden tím upgraduje svoju Protobuf definíciu, regeneruje kód, deployuje—a ticho rozbije každý downstream consumer ktorý ešte neupgradoval.

Prostredie: Protobuf 3.x, Kafka-based event streaming, polyglotné služby (Java, Go, Python), nezávislé deployment scheduly

Pochopenie Schema Evolution Zlyhaní

Ako by Schema Evolution mala fungovať

Producer v1 (stará schema):
message UserEvent {
  string user_id = 1;
  string email = 2;      ← Pole existuje
  string name = 3;
}

Consumer v1 (stará schema):
Číta: user_id ✓, email ✓, name ✓

Producer upgraduje na v2 (nová schema):
message UserEvent {
  string user_id = 1;
  // email pole odstránené! ← Breaking change
  string name = 3;
  string phone = 4;      ← Nové pole pridané
}

Consumer stále na v1 (stará schema):
Číta event z Producer v2:
- user_id: "123" ✓
- email: "" ← TICHO PRÁZDNE (pole nie je v novej schéme)
- name: "John" ✓

Výsledok: Žiaden error, žiaden warning, len chýbajúce data

Štyri smrteľné Schema zmeny

1. ODSTRÁNENIE POĽA (rozbije backward compatibility)
   ┌─────────────────────────────────────────────────┐
   │ Producer v2: odstráni "email"                   │
   │ Consumer v1: očakáva "email"                    │
   │ Výsledok: Consumer dostane prázdnu/default hodnotu│
   │ Impact: TICHÁ STRATA DÁT                        │
   └─────────────────────────────────────────────────┘

2. PRIDANIE REQUIRED POĽA (rozbije backward compatibility)
   ┌─────────────────────────────────────────────────┐
   │ Producer v1: nevie o "phone"                    │
   │ Consumer v2: vyžaduje "phone" (bez defaultu)    │
   │ Výsledok: Consumer dostane prázdne/zlyhá validácia│
   │ Impact: PROCESSING ZLYHANIA alebo INVALIDNÉ DATA│
   └─────────────────────────────────────────────────────┘

3. ZMENA TYPU POĽA (rozbije všetko)
   ┌─────────────────────────────────────────────────┐
   │ Producer v1: user_id je string                  │
   │ Producer v2: user_id zmenené na int64           │
   │ Consumer: deserializácia zlyhá alebo corrupt    │
   │ Impact: PÁDY alebo KORUPCIA DÁT                 │
   └─────────────────────────────────────────────────┘

4. REUSNUTIE FIELD NUMBERS (Protobuf) (corruptnuté data)
   ┌─────────────────────────────────────────────────┐
   │ Producer v1: string email = 2;                  │
   │ Producer v2: int32 age = 2;  ← Reusnutý number! │
   │ Consumer v1: číta field 2 ako string            │
   │ Výsledok: Type confusion, poškodené data        │
   │ Impact: KORUPCIA DÁT                            │
   └─────────────────────────────────────────────────┘

Bežné Schema Evolution katastrofy

1. “Cleanup” ktorý rozbil všetko

// Predtým (v1)
message OrderEvent {
  string order_id = 1;
  string user_email = 2;  // "Už toto nepoužívame"
  double amount = 3;
}

// Potom (v2) - "Vyčistené nepoužívané pole"
message OrderEvent {
  string order_id = 1;
  // user_email odstránený! ← Analytics tím to stále používal
  double amount = 3;
}

Impact: Analytics pipeline ticho prestal zbierať emaily. Trvalo 2 týždne kým sme si všimli.

2. “Optional” pole ktoré nebolo

// Producer pridá nové pole
message UserEvent {
  string user_id = 1;
  string email = 2;
  string phone = 3;  // Nové optional pole
}

// Consumer validačná logika (pridaná bez kontroly schémy):
if event.phone == "" {
  log.Error("Invalid event: phone required")
  return error
}

Impact: Starí produceri (v1) posielajú eventy bez phone. Consumer (v2) ich všetky zamieta ako invalidné.

3. Type Change horror

// v1: user_id bol string
message Event {
  string user_id = 1;
}

// v2: "Spravme to numerické kvôli efektivite"
message Event {
  int64 user_id = 1;  ← BREAKING CHANGE
}

Impact: Deserializácia katastroficky zlyhá. Protobuf možno ani neerrorne—len vráti smetie.

4. Avro Union Expansion

// Avro v1
{
  "type": "record",
  "fields": [
    {"name": "status", "type": {"type": "enum", "symbols": ["PENDING", "COMPLETE"]}}
  ]
}

// Avro v2 - pridaná nová enum hodnota
{
  "type": "record",
  "fields": [
    {"name": "status", "type": {"type": "enum", "symbols": ["PENDING", "COMPLETE", "CANCELLED"]}}
  ]
}

Impact: Starí consumeri nepoznajú “CANCELLED”, môžu spadnúť alebo treatovať ako unknown.

Schema Evolution Contract prístup

Definuj explicitné compatibility pravidlá ktoré CI vynucuje:

# schema_contract.yml
version: 1

schemas:
  user_events:
    format: protobuf
    path: proto/user_events.proto
    compatibility: backward  # Nové schémy musia byť čitateľné starými consumermi

  order_events:
    format: avro
    path: schemas/order_events.avsc
    compatibility: full  # Backward AJ forward compatible

rules:
  - no_field_removal: true        # Nikdy neodstraňuj polia
  - no_type_changes: true         # Nikdy nemeň typy polí
  - no_required_fields: true      # Všetky nové polia musia mať defaulty
  - no_reused_field_numbers: true # Protobuf: nikdy nereusuj numbery

Tento kontrakt hovorí:

  • backward compatibility: Nová producer schema musí byť čitateľná starou consumer schémou
  • forward compatibility: Stará producer schema musí byť čitateľná novou consumer schémou
  • full compatibility: Backward AJ forward (najbezpečnejšie pre nezávislé deploymenty)

Implementácia: CI-Based Schema Validácia

Kľúč: testuj schema compatibility v CI pred merge, použitím reálnych schema evolution nástrojov.

Krok 1: Ulož Schema Históriu

# schemas/user_events/
├── v1.proto  # Historická verzia
├── v2.proto  # Aktuálna verzia v main
└── v3.proto  # Navrhnutý change v PR

Krok 2: Protobuf Compatibility Check (buf)

# buf.yaml - Protobuf linting a breaking change detekcia
version: v1
breaking:
  use:
    - FILE
rules:
    - FIELD_NO_DELETE
    - FIELD_SAME_TYPE
    - FIELD_SAME_CARDINALITY
    - ENUM_VALUE_NO_DELETE
    - ONEOF_NO_DELETE
# V CI: porovnaj s predchádzajúcou verziou
buf breaking --against '.git#branch=main'

Čo to chytí:

  • Odstránenia polí
  • Zmeny typov
  • Odstránenia enum hodnôt
  • Reusnutie field numbers

Krok 3: Avro Compatibility Check (Schema Registry)

// AvroCompatibilityTest.java
import org.apache.avro.Schema;
import org.apache.avro.SchemaCompatibility;
import org.junit.jupiter.api.Test;

class AvroCompatibilityTest {

    @Test
    void newSchemaIsBackwardCompatible() throws Exception {
        // Načítaj schémy
        Schema oldSchema = loadSchema("schemas/user_events/v2.avsc");
        Schema newSchema = loadSchema("schemas/user_events/v3.avsc");

        // Test backward compatibility (nová schema môže čítať staré data)
        var result = SchemaCompatibility.checkReaderWriterCompatibility(
            newSchema,  // reader (nový consumer)
            oldSchema   // writer (starý producer)
        );

        if (result.getCompatibility() != SchemaCompatibility.SchemaCompatibilityType.COMPATIBLE) {
            fail("Schema NIE JE backward compatible: " + result.getDescription());
        }
    }

    @Test
    void newSchemaIsForwardCompatible() throws Exception {
        Schema oldSchema = loadSchema("schemas/user_events/v2.avsc");
        Schema newSchema = loadSchema("schemas/user_events/v3.avsc");

        // Test forward compatibility (stará schema môže čítať nové data)
        var result = SchemaCompatibility.checkReaderWriterCompatibility(
            oldSchema,  // reader (starý consumer)
            newSchema   // writer (nový producer)
        );

        if (result.getCompatibility() != SchemaCompatibility.SchemaCompatibilityType.COMPATIBLE) {
            fail("Schema NIE JE forward compatible: " + result.getDescription());
        }
    }
}

Krok 4: Cross-Version Serialization Test

Ultimátny test: serializuj s novou schémou, deserializuj so starou schémou (a naopak).

// schema_compat_test.go
package schematest

import (
    "testing"
    pb_v2 "yourapp/proto/v2"
    pb_v3 "yourapp/proto/v3"
    "google.golang.org/protobuf/proto"
)

func TestBackwardCompatibility(t *testing.T) {
    // Producer používajúci NOVÚ schému (v3)
    newEvent := &pb_v3.UserEvent{
        UserId: "user-123",
        Name:   "John Doe",
        Phone:  "+1234567890", // Nové pole vo v3
    }

    // Serializuj s v3
    data, err := proto.Marshal(newEvent)
    if err != nil {
        t.Fatal(err)
    }

    // Consumer používajúci STARÚ schému (v2) sa ju snaží čítať
    var oldEvent pb_v2.UserEvent
    err = proto.Unmarshal(data, &oldEvent)
    if err != nil {
        t.Fatalf("Starý consumer ZLYHALO čítať nové producer data: %v", err)
    }

    // Overenie že core polia stále fungujú
    if oldEvent.UserId != "user-123" {
        t.Errorf("user_id mismatch: got %v", oldEvent.UserId)
    }
    if oldEvent.Name != "John Doe" {
        t.Errorf("name mismatch: got %v", oldEvent.Name)
    }

    // oldEvent.Phone neexistuje vo v2, to je OK (forward compatibility)
}

func TestForwardCompatibility(t *testing.T) {
    // Producer používajúci STARÚ schému (v2)
    oldEvent := &pb_v2.UserEvent{
        UserId: "user-456",
        Name:   "Jane Smith",
        // Žiadne phone pole vo v2
    }

    // Serializuj s v2
    data, err := proto.Marshal(oldEvent)
    if err != nil {
        t.Fatal(err)
    }

    // Consumer používajúci NOVÚ schému (v3) sa ju snaží čítať
    var newEvent pb_v3.UserEvent
    err = proto.Unmarshal(data, &newEvent)
    if err != nil {
        t.Fatalf("Nový consumer ZLYHALO čítať staré producer data: %v", err)
    }

    // Overenie polí
    if newEvent.UserId != "user-456" {
        t.Errorf("user_id mismatch: got %v", newEvent.UserId)
    }

    // Phone by malo byť prázdne (default hodnota) - toto je očakávané
    if newEvent.Phone != "" {
        t.Errorf("Očakávané prázdne phone, got %v", newEvent.Phone)
    }
}

CI Integrácia (GitHub Actions)

name: schema-evolution-contract

on: [pull_request]

jobs:
  schema:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Potreba história pre buf breaking

      - uses: bufbuild/buf-setup-action@v1

      - name: Skontroluj Protobuf breaking changes
        run: |
          buf breaking --against '.git#branch=main'

      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '21'

      - name: Testuj Avro schema kompatibilitu
        run: ./gradlew test --tests AvroCompatibilityTest

      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'

      - name: Testuj cross-version serializáciu
        run: go test ./pkg/schematest -v

      - name: Zlyhá ak nekompatibilné
        if: failure()
        run: |
          echo "❌ Schema zmena rozbíja kompatibilitu!"
          echo "Pozri https://protobuf.dev/programming-guides/proto3/#updating"
          exit 1

Toto beží na každej PR a blokuje merge ak je schema compatibility rozbitá.

Runtime Monitoring (Produkcia)

CI chytí design problémy. Produkcia potrebuje monitoring pre reálne incompatibility:

# Počet deserializačných errorov
increase(protobuf_unmarshal_errors_total[5m]) > 10

# Track schema version skew
max(schema_version{topic="user-events"})
- min(schema_version{topic="user-events"})
> 2  # Viac ako 2 verzie odlišné

# Alert na unknown fields (forward incompatibility signal)
increase(protobuf_unknown_fields_total[5m]) > 100

Instrumentuj tvoje deserializery:

func decodeEvent(data []byte) (*Event, error) {
    var event Event
    err := proto.Unmarshal(data, &event)

    if err != nil {
        unmarshalErrors.Inc()
        return nil, err
    }

    // Skontroluj unknown fields (Protobuf ich drží)
    if len(event.XXX_unrecognized) > 0 {
        unknownFields.Inc()
        log.Warn("Event obsahuje unknown fields - schema version skew?")
    }

    return &event, nil
}

Keď sa Schema Break stane v produkcii

Krok 1: Identifikuj Breaking Change

# Skontroluj schema verzie v používaní
kubectl exec -it producer-pod -- env | grep SCHEMA_VERSION
kubectl exec -it consumer-pod -- env | grep SCHEMA_VERSION

# Porovnaj schémy
buf breaking --against 'https://github.com/yourorg/schemas#branch=v2.3.0'

Krok 2: Okamžitá mitigácia

Možnosť 1: Rollback producer (najbezpečnejšie)

# Vráť na predchádzajúcu producer verziu
kubectl rollout undo deployment/event-producer

Možnosť 2: Fast-forward consumer (ak možné)

# Deployni consumer s novou schémou
# Len ak nový consumer zvládne staré producer data
kubectl set image deployment/event-consumer app=consumer:v3

Možnosť 3: Replay s korektnou schémou

# Ak data boli stratené, replay Kafka topic s kompatibilným consumerom
kafka-consumer-groups --reset-offsets --to-earliest

Krok 3: Oprav Schému

Pre odstránené pole:

// Zlé: pole odstránené
message UserEvent {
  string user_id = 1;
  // string email = 2;  ← REMOVED
}

// Dobré: označ deprecated namiesto toho
message UserEvent {
  string user_id = 1;
  string email = 2 [deprecated = true];  // Ponechaj pre compatibility
}

Pre pridané required pole:

// Zlé: nové pole bez defaultu
message UserEvent {
  string user_id = 1;
  string phone = 2;  // Starí produceri toto nepošlú!
}

// Dobré: urob optional alebo poskytni default
message UserEvent {
  string user_id = 1;
  optional string phone = 2;  // Explicitne optional
}

Pre zmenu typu:

// Zlé: zmenený typ
message UserEvent {
  int64 user_id = 1;  // Bol string!
}

// Dobré: pridaj NOVÉ pole s novým typom, deprecni staré
message UserEvent {
  string user_id = 1 [deprecated = true];
  int64 user_id_v2 = 4;  // Nové pole, nový number
}

Checklist

## Schema Evolution Checklist

### Pred zmenou Schémy
- [ ] Skontroluj aké consumery existujú pre túto schému
- [ ] Urči požadovanú kompatibilitu (backward/forward/full)
- [ ] Nikdy neodstraňuj polia (deprecni namiesto toho)
- [ ] Nikdy nemeň typy polí (pridaj nové pole namiesto toho)
- [ ] Nikdy nereusuj Protobuf field numbers
- [ ] Všetky nové polia musia mať defaulty alebo byť optional

### CI Kontrakt
- [ ] buf breaking check pre Protobuf
- [ ] Avro SchemaCompatibility test
- [ ] Cross-version serialization test (starý→nový, nový→starý)
- [ ] Testuj s realistickými data samples

### Produkčné Monitorovanie
- [ ] Alert na deserializačné errory
- [ ] Alert na schema version skew > 2 verzie
- [ ] Track unknown field warningy
- [ ] Dashboard ukazujúci schema verzie naprieč službami

### Keď je Break detekovaný
- [ ] Identifikuj ktorá schema verzia rozbila kompatibilitu
- [ ] Rollback producer ALEBO fast-forward consumer
- [ ] Oprav schému (pridaj pole späť, urob optional, atď.)
- [ ] Replay stratené data ak treba

Záver

Schema evolution je neviditeľný API kontrakt. Zmeníš Protobuf pole, regeneruješ kód, deploynešš—a ticho rozbijéš každý consumer ktorý ešte neupgradoval. Žiaden compile error, žiaden runtime exception, len chýbajúce data v produkcii.

Schema Evolution Contracts spravía z “nerozbij schému” vynucovaný CI gate namiesto code review komentára. Testovaním backward a forward compatibility s reálnou serializáciou chytíš breaking changes pred produkciou.

Kľúčový insight: schema kompatibilita nie je dokumentačný problém, je to testovací problém. Nemôžeš code reviewom z toho von—potrebuješ CI aby serializovalo data so schémou v3 a deserializovalo so schémou v2, a zlyhalo build ak sa to rozbije.

Kľúčové princípy:

  1. Nikdy neodstraňuj polia—deprecni ich namiesto toho
  2. Nikdy nemeň typy—pridaj nové pole s novým numbrom
  3. Vždy pridaj defaulty—nové polia musia byť optional alebo mať default hodnoty
  4. Testuj naprieč verziami—serializuj s novou, deserializuj so starou (a naopak)
  5. Monitoruj version skew—alertuj keď producer/consumer schémy odídu príliš ďaleko

Nabudúce keď niekto navrhne “vyčistiť nepoužívané Protobuf polia,” spýtaj sa: “Spustili sme schema evolution kontrakt?”


Súvisiace články

Súvisiace články

Citujte tento článok

Ak na článok odkazujete, pridajte pôvodnú URL a uveďte autora.

Michal Drozd. "Polia zmizli ale nič nespadlo: Zachyť Schema Evolution bugy pred produkciou". https://www.michal-drozd.com/sk/blog/schema-evolution-contracts/ (Publikované 8. októbra 2025).