Späť na blog

Protobuf evolúcia v eventoch: Prečo buf breaking nestačí

|
| protobuf, event-sourcing, architecture, grpc, schema

Protobufy som bral ako stabilne, kym mismatch nezozral eventy. “Schému vieš udržať kompatibilnú, až kým nezistíš, že musíš prečítať event z 2019.” Toto je realita event-driven systémov. Protobuf wire format je backward compatible, ale tvoja aplikačná logika nie vždy.

Buf breaking checks v CI zachytia syntaktické breaking changes. Nezachytia však sémantické problémy - keď field zmenil význam alebo business logiku.

Testované na: Protobuf 3, buf CLI 1.28+, Kafka + event store. Produkčne na systémoch s 3+ rokmi event histórie.

Typy Kompatibility

Wire Compatibility (Protobuf garantuje)

// v1
message OrderCreated {
  string order_id = 1;
  int32 amount = 2;
}

// v2 - wire compatible
message OrderCreated {
  string order_id = 1;
  int64 amount = 2;  // int32 → int64 je OK
  string currency = 3;  // nový optional field je OK
}

Sémantická Kompatibilita (Ty musíš garantovať)

// v1 - amount v centoch
message OrderCreated {
  int32 amount = 2;  // 1000 = $10.00
}

// v2 - amount v dolároch (BREAKING!)
message OrderCreated {
  int32 amount = 2;  // 1000 = $1000.00 !!!
}

Buf to nezachytí. Stará aplikácia číta nové eventy a interpretuje $10 ako $1000.

Pravidlá pre .proto Súbory

Nikdy Nerob

// 1. NIKDY nemeň typ poľa nekompatibilne
string order_id = 1;  →  int64 order_id = 1;  // ZAKÁZANÉ

// 2. NIKDY nemeň číslo poľa
string order_id = 1;  →  string order_id = 2;  // ZAKÁZANÉ

// 3. NIKDY neodstráň required pole (proto2)
required string name = 1;  // Ak existuje, musí zostať

// 4. NIKDY nepremenuj enum hodnotu (ak používaš name matching)
enum Status {
  PENDING = 0;
  ACTIVE = 1;   →   RUNNING = 1;  // ZAKÁZANÉ
}

Bezpečné Zmeny

// 1. Pridaj optional pole
message Order {
  string id = 1;
  string customer_id = 2;  // NOVÉ - OK
}

// 2. Deprecate pole (neodstráň)
message Order {
  string id = 1;
  string old_field = 2 [deprecated = true];
}

// 3. Rozšír enum (ale pozor na default)
enum Status {
  UNKNOWN = 0;  // Vždy maj UNKNOWN/UNSPECIFIED = 0
  PENDING = 1;
  ACTIVE = 2;
  CANCELLED = 3;  // NOVÉ - OK
}

// 4. Zmeň optional na repeated (wire compatible)
string tag = 1;  →  repeated string tags = 1;  // OK

Buf CI Setup

buf.yaml

version: v1
breaking:
  use:
    - FILE
  except:
    - FIELD_SAME_JSON_NAME  # Povoľ JSON name zmeny
lint:
  use:
    - DEFAULT
  except:
    - PACKAGE_VERSION_SUFFIX

buf.gen.yaml

version: v1
plugins:
  - plugin: buf.build/protocolbuffers/go
    out: gen/go
    opt: paths=source_relative
  - plugin: buf.build/grpc/go
    out: gen/go
    opt: paths=source_relative

GitHub Action

name: Protobuf Check

on: [pull_request]

jobs:
  buf:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: bufbuild/buf-setup-action@v1
        with:
          version: '1.28.0'

      - name: Lint
        run: buf lint

      - name: Breaking change check
        run: buf breaking --against '.git#branch=main'

      - name: Generate
        run: buf generate

      - name: Verify generated files
        run: |
          git diff --exit-code gen/

Upcaster Pattern pre Event Evolution

Keď sa schéma zmení a máš staré eventy:

interface Event {
  type: string;
  version: number;
  payload: unknown;
  timestamp: Date;
}

interface Upcaster<TOld, TNew> {
  inputVersion: number;
  outputVersion: number;
  upcast(old: TOld): TNew;
}

class OrderCreatedV1ToV2 implements Upcaster<OrderCreatedV1, OrderCreatedV2> {
  inputVersion = 1;
  outputVersion = 2;

  upcast(old: OrderCreatedV1): OrderCreatedV2 {
    return {
      orderId: old.orderId,
      amount: old.amount,
      currency: 'USD',  // Default pre staré eventy
      createdAt: old.timestamp,
    };
  }
}

class EventUpcastingService {
  private upcasters: Map<string, Upcaster<any, any>[]> = new Map();

  register(eventType: string, upcaster: Upcaster<any, any>) {
    const chain = this.upcasters.get(eventType) || [];
    chain.push(upcaster);
    chain.sort((a, b) => a.inputVersion - b.inputVersion);
    this.upcasters.set(eventType, chain);
  }

  upcast<T>(event: Event, targetVersion: number): T {
    const chain = this.upcasters.get(event.type) || [];
    let payload = event.payload;
    let currentVersion = event.version;

    for (const upcaster of chain) {
      if (currentVersion >= targetVersion) break;
      if (upcaster.inputVersion === currentVersion) {
        payload = upcaster.upcast(payload);
        currentVersion = upcaster.outputVersion;
      }
    }

    return payload as T;
  }
}

Versioning Stratégie

Option 1: Version v Názve Eventu

// events/order/v1/order_created.proto
package events.order.v1;

message OrderCreated {
  string order_id = 1;
}

// events/order/v2/order_created.proto
package events.order.v2;

message OrderCreated {
  string order_id = 1;
  Money amount = 2;
}

Option 2: Version Field v Evente

message OrderCreated {
  int32 schema_version = 1;
  string order_id = 2;

  // V2 fields
  optional Money amount = 3;
}

Option 3: Envelope Pattern

message EventEnvelope {
  string event_type = 1;
  int32 schema_version = 2;
  google.protobuf.Any payload = 3;
  google.protobuf.Timestamp timestamp = 4;
}

Production Checklist

## Protobuf Event Evolution Checklist

### Schema Design
- [ ] Vždy UNKNOWN = 0 pre enums
- [ ] Optional fields pre všetko nové
- [ ] Nikdy nemaž polia, len deprecate
- [ ] Dokumentuj sémantiku v komentároch

### CI/CD
- [ ] buf lint v každom PR
- [ ] buf breaking against main/master
- [ ] Generované súbory v repo (nie runtime)
- [ ] Version tag pre schema changes

### Evolution
- [ ] Upcasters pre major zmeny
- [ ] Backward compatibility testy
- [ ] Dual-read period pre breaking changes
- [ ] Monitoring starých verzií

### Documentation
- [ ] ADR pre každú schema zmenu
- [ ] Changelog pre .proto súbory
- [ ] Migration guide pre consumers

Záver

Protobuf evolúcia v event-driven systémoch vyžaduje viac než buf breaking checks:

  1. Sémantická kompatibilita - význam polí sa nesmie meniť
  2. Upcasters - transformuj staré eventy na nový formát
  3. Versioning - jasná stratégia pre major zmeny
  4. Testing - backward compatibility ako súčasť CI

FAQ

Môžem zmazať nepoužívané pole?

Nie. Označ ako deprecated a rezervuj číslo (reserved 5;). Staré eventy ho stále môžu obsahovať.

Čo ak potrebujem breaking change?

Vytvor nový event type (OrderCreatedV2), dual-write počas migrácie, potom ukončí starý.

Koľko verzií podporovať?

Minimum: všetky verzie v event store. Prakticky: 2-3 major verzie s upcast chain.


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. "Protobuf evolúcia v eventoch: Prečo buf breaking nestačí". https://www.michal-drozd.com/sk/blog/protobuf-event-evolution/ (Publikované 6. júla 2025).