mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-04 08:02:01 +00:00
374 lines
13 KiB
Go
374 lines
13 KiB
Go
package models
|
|
|
|
import (
|
|
"encoding"
|
|
"fmt"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/hamba/avro/v2"
|
|
)
|
|
|
|
// Avro has to be told the shape of a union before it meets one, and the two bus
|
|
// envelopes carry a body whose type depends on a discriminator. This derives
|
|
// each envelope's schema from the body registry rather than from a checked-in
|
|
// .avsc, so a field added to a payload cannot drift from the schema the registry
|
|
// holds: both come from the same struct.
|
|
//
|
|
// The serializer prefers a Schema() method over reflecting on the value it was
|
|
// handed, which is the only reason an `any` body can be encoded at all:
|
|
// reflection alone sees an interface and has nothing to describe.
|
|
//
|
|
// The walk below is ours rather than the one the Confluent serializer ships,
|
|
// for two reasons that are really one. It skips unexported fields, and it
|
|
// honours `avro:"-"` before descending rather than after. The upstream walk
|
|
// does neither, so oauth2.Token's unexported `raw any` and the already-excluded
|
|
// oauth2.TokenSource both made the schema unbuildable. Doing it this way also
|
|
// means the schema describes exactly the fields encoding/json puts on the wire
|
|
// today, which is what makes the two codecs interchangeable during a cutover.
|
|
|
|
var (
|
|
eventSchemaOnce sync.Once
|
|
workerEventSchema avro.Schema
|
|
jobEventSchema avro.Schema
|
|
)
|
|
|
|
// Building the schemas registers every union body's Go type, and that
|
|
// registration is what lets a decoder turn a branch back into the type a
|
|
// handler asserts on. Doing it here rather than on first use is the difference
|
|
// between a working consumer and a silently broken one: a process that only
|
|
// ever reads, which is what a worker is until it first publishes, would
|
|
// otherwise decode every body into a map[string]any keyed by the branch name,
|
|
// hand it to the JSON fallback, and produce a struct with every field zero. No
|
|
// error anywhere. That shipped once and took out all 39 mailboxes.
|
|
func init() { buildEventSchemas() }
|
|
|
|
// Schema lets the Avro serializer encode a worker command without reflecting
|
|
// over its interface-typed body.
|
|
func (WorkerEvent) Schema() avro.Schema { buildEventSchemas(); return workerEventSchema }
|
|
|
|
// Schema is the same for everything a worker reports back.
|
|
func (JobEvent) Schema() avro.Schema { buildEventSchemas(); return jobEventSchema }
|
|
|
|
func buildEventSchemas() {
|
|
eventSchemaOnce.Do(func() {
|
|
var err error
|
|
if workerEventSchema, err = envelopeSchema("WorkerEvent", distinctBodies(WorkerEventBodies)); err != nil {
|
|
panic(fmt.Sprintf("models: worker event schema: %v", err))
|
|
}
|
|
if jobEventSchema, err = envelopeSchema("JobEvent", distinctBodies(JobEventBodies)); err != nil {
|
|
panic(fmt.Sprintf("models: job event schema: %v", err))
|
|
}
|
|
})
|
|
}
|
|
|
|
// envelopeSchema is the record both envelopes share: the discriminator, and a
|
|
// union of every body it can carry.
|
|
func envelopeSchema(name string, bodies []any) (avro.Schema, error) {
|
|
// One cache per schema document. Avro names a record once and refers to it
|
|
// by name after that, so the same Go struct reached through two fields has
|
|
// to resolve to the same schema object: oauth2.Token is reachable three
|
|
// ways through a single worker command, and defining it three times is a
|
|
// document the registry rejects outright.
|
|
//
|
|
// Per document rather than global, because a name defined in one envelope's
|
|
// schema means nothing in the other's.
|
|
seen := map[reflect.Type]avro.Schema{}
|
|
branches := make([]avro.Schema, 0, len(bodies))
|
|
for _, body := range bodies {
|
|
t := reflect.TypeOf(body)
|
|
for t.Kind() == reflect.Pointer {
|
|
t = t.Elem()
|
|
}
|
|
s, err := schemaOf(t, "", seen)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", t, err)
|
|
}
|
|
record, ok := s.(*avro.RecordSchema)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%s: resolved to %s, not a record", t, s.Type())
|
|
}
|
|
// Registered with the pointer-ness the registry declared. A body sent as
|
|
// a value cannot resolve to a branch registered as a pointer, and the
|
|
// handler's type assertion is what decides whether the decoded body is
|
|
// used directly or re-marshalled through JSON.
|
|
avro.Register(record.FullName(), body)
|
|
branches = append(branches, record)
|
|
}
|
|
body, err := avro.NewUnionSchema(branches)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("union: %w", err)
|
|
}
|
|
typeField, err := avro.NewField("type", avro.NewPrimitiveSchema(avro.String, nil))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
bodyField, err := avro.NewField("body", body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return avro.NewRecordSchema(name, "warmbly.events", []*avro.Field{typeField, bodyField})
|
|
}
|
|
|
|
var textMarshaler = reflect.TypeOf((*encoding.TextMarshaler)(nil)).Elem()
|
|
|
|
// schemaOf describes one Go type. `name` disambiguates anonymous records.
|
|
func schemaOf(t reflect.Type, name string, seen map[reflect.Type]avro.Schema) (avro.Schema, error) {
|
|
// Pointers are unwrapped before anything else is asked about the type.
|
|
// *time.Time satisfies TextMarshaler exactly as time.Time does, so testing
|
|
// that first sent every optional timestamp as a string and the zero value
|
|
// then failed to parse on the way back.
|
|
if t.Kind() == reflect.Pointer {
|
|
// Optional, so an absent value stays absent instead of decoding as a
|
|
// zero the receiver cannot tell from a real one.
|
|
inner, err := schemaOf(t.Elem(), name, seen)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return avro.NewUnionSchema([]avro.Schema{&avro.NullSchema{}, inner})
|
|
}
|
|
// A timestamp is a number with a logical type, not text: it is ordered and
|
|
// compared by anything reading the topic.
|
|
if t == reflect.TypeOf(time.Time{}) {
|
|
return avro.NewPrimitiveSchema(avro.Long, avro.NewPrimitiveLogicalSchema(avro.TimestampMillis)), nil
|
|
}
|
|
// A type that writes itself as text travels as a string, which is how
|
|
// uuid.UUID and the enum-ish string types stay readable in the registry.
|
|
if t.Implements(textMarshaler) || reflect.PointerTo(t).Implements(textMarshaler) {
|
|
return avro.NewPrimitiveSchema(avro.String, nil), nil
|
|
}
|
|
switch t.Kind() {
|
|
case reflect.Bool:
|
|
return avro.NewPrimitiveSchema(avro.Boolean, nil), nil
|
|
case reflect.String:
|
|
return avro.NewPrimitiveSchema(avro.String, nil), nil
|
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32:
|
|
return avro.NewPrimitiveSchema(avro.Int, nil), nil
|
|
case reflect.Int64, reflect.Uint, reflect.Uint16, reflect.Uint32:
|
|
return avro.NewPrimitiveSchema(avro.Long, nil), nil
|
|
case reflect.Uint64:
|
|
// Avro's long is signed, and half a uint64 does not fit in one. Eight
|
|
// fixed bytes carry the whole range, which is what a sync cursor needs:
|
|
// an IMAP MODSEQ and a Gmail history id are both opaque counters, and
|
|
// one that wrapped negative would be persisted and reloaded as a
|
|
// different position in the mailbox.
|
|
return named(t, seen, func() (avro.NamedSchema, error) {
|
|
return avro.NewFixedSchema("uint64", "warmbly.events", 8, nil)
|
|
})
|
|
case reflect.Float32:
|
|
return avro.NewPrimitiveSchema(avro.Float, nil), nil
|
|
case reflect.Float64:
|
|
return avro.NewPrimitiveSchema(avro.Double, nil), nil
|
|
case reflect.Slice, reflect.Array:
|
|
if t.Elem().Kind() == reflect.Uint8 {
|
|
return avro.NewPrimitiveSchema(avro.Bytes, nil), nil
|
|
}
|
|
inner, err := schemaOf(t.Elem(), name, seen)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return avro.NewArraySchema(inner), nil
|
|
case reflect.Map:
|
|
if t.Key().Kind() != reflect.String {
|
|
return nil, fmt.Errorf("map key %s is not a string", t.Key())
|
|
}
|
|
inner, err := schemaOf(t.Elem(), name, seen)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return avro.NewMapSchema(inner), nil
|
|
case reflect.Struct:
|
|
return recordSchema(t, name, seen)
|
|
default:
|
|
return nil, fmt.Errorf("unsupported kind %s", t.Kind())
|
|
}
|
|
}
|
|
|
|
func recordSchema(t reflect.Type, name string, seen map[reflect.Type]avro.Schema) (avro.Schema, error) {
|
|
if cached, ok := seen[t]; ok {
|
|
return reference(cached), nil
|
|
}
|
|
fields := make([]*avro.Field, 0, t.NumField())
|
|
for i := 0; i < t.NumField(); i++ {
|
|
f := t.Field(i)
|
|
// Unexported fields are not on the wire under any codec: encoding/json
|
|
// cannot see them either. Descending into one is how a dependency's
|
|
// private `any` field became our problem.
|
|
if !f.IsExported() {
|
|
continue
|
|
}
|
|
fieldName := f.Tag.Get("avro")
|
|
// Checked before descending, which is the half upstream gets wrong: a
|
|
// field excluded on purpose must not have to be describable.
|
|
if fieldName == "-" {
|
|
continue
|
|
}
|
|
if fieldName == "" {
|
|
fieldName = f.Name
|
|
}
|
|
s, err := schemaOf(f.Type, t.Name()+"_"+f.Name, seen)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s.%s: %w", t.Name(), f.Name, err)
|
|
}
|
|
// Every field carries its zero as an Avro default, so a reader on a
|
|
// schema that gained a field can still decode data written before the
|
|
// field existed. Without one the registry refuses the new schema
|
|
// outright under BACKWARD and the publisher cannot serialize (#583).
|
|
opts := []avro.SchemaOption{}
|
|
if def, ok := zeroDefault(s, 0); ok {
|
|
opts = append(opts, avro.WithDefault(def))
|
|
}
|
|
field, err := avro.NewField(fieldName, s, opts...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s.%s: %w", t.Name(), f.Name, err)
|
|
}
|
|
fields = append(fields, field)
|
|
}
|
|
recordName := t.Name()
|
|
if recordName == "" {
|
|
recordName = name
|
|
}
|
|
record, err := avro.NewRecordSchema(recordName, "warmbly.events", fields)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
seen[t] = record
|
|
return record, nil
|
|
}
|
|
|
|
// maxDefaultDepth bounds the walk below. A Go type that reaches itself would
|
|
// otherwise recurse forever through its own reference, and a field we cannot
|
|
// describe a default for is left without one rather than failing the schema.
|
|
const maxDefaultDepth = 12
|
|
|
|
// zeroDefault is the Avro default matching a schema's zero value, and whether
|
|
// one could be derived at all. It mirrors what the Go zero value encodes to,
|
|
// so a reader falling back to the default sees what a writer that never set
|
|
// the field would have sent.
|
|
func zeroDefault(s avro.Schema, depth int) (any, bool) {
|
|
if depth > maxDefaultDepth {
|
|
return nil, false
|
|
}
|
|
switch s.Type() {
|
|
case avro.Null:
|
|
return nil, true
|
|
case avro.Boolean:
|
|
return false, true
|
|
case avro.Int:
|
|
return 0, true
|
|
case avro.Long:
|
|
return int64(0), true
|
|
case avro.Float:
|
|
return float32(0), true
|
|
case avro.Double:
|
|
return float64(0), true
|
|
case avro.String, avro.Bytes:
|
|
return "", true
|
|
case avro.Array:
|
|
return []any{}, true
|
|
case avro.Map:
|
|
return map[string]any{}, true
|
|
case avro.Fixed:
|
|
// Spec: a fixed default is a string whose code points 0-255 are the
|
|
// byte values, so the zero is that many NUL code points.
|
|
return strings.Repeat("\x00", s.(*avro.FixedSchema).Size()), true
|
|
case avro.Enum:
|
|
symbols := s.(*avro.EnumSchema).Symbols()
|
|
if len(symbols) == 0 {
|
|
return nil, false
|
|
}
|
|
return symbols[0], true
|
|
case avro.Union:
|
|
// A union default is written against its first branch, which for our
|
|
// optional fields is null.
|
|
types := s.(*avro.UnionSchema).Types()
|
|
if len(types) == 0 {
|
|
return nil, false
|
|
}
|
|
return zeroDefault(types[0], depth+1)
|
|
case avro.Ref:
|
|
return zeroDefault(s.(*avro.RefSchema).Schema(), depth+1)
|
|
case avro.Record:
|
|
m := map[string]any{}
|
|
for _, f := range s.(*avro.RecordSchema).Fields() {
|
|
v, ok := zeroDefault(f.Type(), depth+1)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
m[f.Name()] = v
|
|
}
|
|
return m, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// named defines a schema the first time its Go type is seen and refers to it
|
|
// every time after. Avro names a type once, and a document that defines the
|
|
// same name twice is rejected outright; reusing the same schema object is not
|
|
// enough, because it is serialised in full wherever it appears.
|
|
func named(t reflect.Type, seen map[reflect.Type]avro.Schema, build func() (avro.NamedSchema, error)) (avro.Schema, error) {
|
|
if cached, ok := seen[t]; ok {
|
|
return reference(cached), nil
|
|
}
|
|
s, err := build()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
seen[t] = s
|
|
return s, nil
|
|
}
|
|
|
|
func reference(s avro.Schema) avro.Schema {
|
|
if n, ok := s.(avro.NamedSchema); ok {
|
|
return avro.NewRefSchema(n)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// distinctBodies reduces a body registry to the distinct types in it, in a
|
|
// stable order. Distinct because several event types share a body and a union
|
|
// may not list a branch twice; stable because branch order is part of the
|
|
// schema, and one that reordered itself between boots would register a new
|
|
// version on every deploy.
|
|
func distinctBodies[K ~string](registry map[K]any) []any {
|
|
seen := map[reflect.Type]bool{}
|
|
out := make([]any, 0, len(registry))
|
|
for _, body := range registry {
|
|
t := reflect.TypeOf(body)
|
|
for t.Kind() == reflect.Pointer {
|
|
t = t.Elem()
|
|
}
|
|
if seen[t] {
|
|
continue
|
|
}
|
|
seen[t] = true
|
|
out = append(out, body)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return elemName(out[i]) < elemName(out[j]) })
|
|
return out
|
|
}
|
|
|
|
func elemName(body any) string {
|
|
t := reflect.TypeOf(body)
|
|
for t.Kind() == reflect.Pointer {
|
|
t = t.Elem()
|
|
}
|
|
return t.Name()
|
|
}
|
|
|
|
// SchemaFor derives a record schema for a plain event struct. The analytics
|
|
// streams carry no union, so they have nothing a reflecting serializer could
|
|
// fail to disambiguate; they need this only because the same walk has to
|
|
// describe them, or their records drift from the envelopes' on tags, time and
|
|
// uuid handling.
|
|
func SchemaFor(v any) (avro.Schema, error) {
|
|
t := reflect.TypeOf(v)
|
|
for t.Kind() == reflect.Pointer {
|
|
t = t.Elem()
|
|
}
|
|
return schemaOf(t, "", map[reflect.Type]avro.Schema{})
|
|
}
|