Files
warmbly/internal/infrastructure/codec/avro.go
T
Matthew Meszaros f106c8541d feat: make the bus envelopes Avro-encodable (#535)
* feat: make both bus envelopes Avro-encodable by deriving each one's schema from a declared registry of body types, with a union branch per body and our own struct walk that skips unexported fields and honours avro:"-" before descending, so the schema describes exactly what encoding/json already puts on the wire, and narrow the two sync cursors on the wire DTOs to int64 because Avro has no unsigned 64-bit type

* feat: stop the instance health check, the config registry and the docs all claiming Avro cannot serialize a worker envelope, which stopped being true once the envelopes carried a declared union, and check the one thing that is still a real misconfiguration instead: avro selected with no SCHEMA_REGISTRY_URL to resolve against

* feat: emit a reference the second time a record appears in an envelope schema instead of defining it again, because Avro names a record once and a document that defines warmbly.events.Token three times is rejected outright, and keep the Schema Registry round-trip as a skip-by-default test since only a registry judges the document rather than the objects it was built from

* feat: carry uint64 as Avro fixed(8) rather than long, which lets the sync cursors keep their unsigned type instead of being narrowed, name every event field after its json tag so the schema and the JSON wire agree, and populate every field in the round-trip test because zero values are why a uint64 mapped to long passed in the first place

* feat: frame Avro in Confluent's wire format and encode through hamba's default API instead of going through avrov2, whose private avro.API holds a type resolver avro.Register cannot reach, so a union body failed there with unable to resolve type while encoding cleanly against the same schema, and keep the registry round-trip as a skip-by-default test
2026-09-15 10:50:30 -07:00

156 lines
5.2 KiB
Go

//go:build kafka
// The Avro codec frames events in Confluent's wire format and resolves schemas
// against a Schema Registry. It is compiled only with the `kafka` build tag,
// because the registry client reaches the cgo kafka package and an instance not
// on Kafka has no registry to resolve against.
package codec
import (
"context"
"encoding/binary"
"errors"
"fmt"
"sync"
"github.com/confluentinc/confluent-kafka-go/v2/schemaregistry"
"github.com/hamba/avro/v2"
)
// Confluent's framing: a zero byte, the schema's registry id big-endian, then
// the Avro body. Five bytes, and the only thing standing between a registry and
// a payload.
const (
wireMagic = 0
wireHeader = 5
)
// AvroCodec encodes through hamba's default API rather than Confluent's
// avrov2 serializer, which is the one thing here worth explaining.
//
// Both bus envelopes carry `Body any`, and hamba resolves which union branch an
// `any` belongs to through a type resolver that `avro.Register` writes to. That
// registry is `avro.DefaultConfig`'s. avrov2 marshals with `avro.Config{}.Freeze()`,
// a private API holding its own empty resolver, and exposes no way to add to
// it, so every worker command failed there with "unable to resolve type" while
// encoding perfectly against the same schema through the default API.
//
// So the registry client is used for what it is good at, turning a schema into
// an id and back, and the encoding is done where the types are known.
type AvroCodec struct {
client schemaregistry.Client
mu sync.RWMutex
ids map[string]int // subject + schema -> registered id
schemas map[int]avro.Schema // id -> parsed schema, for decoding
}
// NewAvro constructs an AvroCodec from Schema Registry credentials.
func NewAvro(schemaRegistryURL, schemaRegistryKey, schemaRegistrySecret string) (Codec, error) {
client, err := schemaregistry.NewClient(schemaregistry.NewConfigWithBasicAuthentication(
schemaRegistryURL, schemaRegistryKey, schemaRegistrySecret,
))
if err != nil {
return nil, fmt.Errorf("codec: schema registry: %w", err)
}
return &AvroCodec{
client: client,
ids: map[string]int{},
schemas: map[int]avro.Schema{},
}, nil
}
// Name satisfies Codec.
func (c *AvroCodec) Name() string { return "avro" }
var _ Codec = (*AvroCodec)(nil)
// Serialize registers the value's schema under the topic's subject, then writes
// the framed payload. A value that describes its own schema is asked for it;
// anything else is refused rather than guessed at, because guessing is what
// produced an interface with nothing to describe.
func (c *AvroCodec) Serialize(_ context.Context, topic string, value any) ([]byte, error) {
described, ok := value.(interface{ Schema() avro.Schema })
if !ok {
return nil, fmt.Errorf("codec: %T does not carry an Avro schema", value)
}
schema := described.Schema()
id, err := c.register(subjectFor(topic), schema)
if err != nil {
return nil, err
}
body, err := avro.Marshal(schema, value)
if err != nil {
return nil, fmt.Errorf("codec: encode %T: %w", value, err)
}
out := make([]byte, wireHeader, wireHeader+len(body))
out[0] = wireMagic
binary.BigEndian.PutUint32(out[1:], uint32(id))
return append(out, body...), nil
}
// Deserialize decodes with the schema the payload names, which is the writer's
// rather than whatever this process happens to hold. That is the whole point of
// carrying the id: a consumer reads what was actually written.
func (c *AvroCodec) Deserialize(_ context.Context, topic string, payload []byte, target any) error {
if len(payload) < wireHeader || payload[0] != wireMagic {
return errors.New("codec: payload is not in the schema registry wire format")
}
id := int(binary.BigEndian.Uint32(payload[1:wireHeader]))
schema, err := c.schemaByID(subjectFor(topic), id)
if err != nil {
return err
}
if err := avro.Unmarshal(schema, payload[wireHeader:], target); err != nil {
return fmt.Errorf("codec: decode into %T: %w", target, err)
}
return nil
}
// register is memoised because the registry answers the same question with the
// same id forever, and a lookup per published event would put the registry on
// the send path.
func (c *AvroCodec) register(subject string, schema avro.Schema) (int, error) {
key := subject + "\x00" + schema.String()
c.mu.RLock()
id, ok := c.ids[key]
c.mu.RUnlock()
if ok {
return id, nil
}
id, err := c.client.Register(subject, schemaregistry.SchemaInfo{Schema: schema.String()}, true)
if err != nil {
return 0, fmt.Errorf("codec: register %s: %w", subject, err)
}
c.mu.Lock()
c.ids[key] = id
c.mu.Unlock()
return id, nil
}
func (c *AvroCodec) schemaByID(subject string, id int) (avro.Schema, error) {
c.mu.RLock()
schema, ok := c.schemas[id]
c.mu.RUnlock()
if ok {
return schema, nil
}
info, err := c.client.GetBySubjectAndID(subject, id)
if err != nil {
return nil, fmt.Errorf("codec: schema %d: %w", id, err)
}
schema, err = avro.Parse(info.Schema)
if err != nil {
return nil, fmt.Errorf("codec: schema %d is not readable: %w", id, err)
}
c.mu.Lock()
c.schemas[id] = schema
c.mu.Unlock()
return schema, nil
}
// subjectFor is Confluent's TopicNameStrategy, the default everywhere else.
func subjectFor(topic string) string { return topic + "-value" }