Skip to content

CanonicalSchema() is non-deterministic when a field name matches a record type name in the same namespace #307

Description

@jwilliams-vc

Summary

CanonicalSchema() produces two different output strings from the same schema input, non-deterministically (~14% vs ~86% split), when a field name and a record type name share the same local name within the same inherited namespace.

Reproduction

package main

import (
	"fmt"
	"github.com/linkedin/goavro/v2"
)

const schema = `{
  "type": "record",
  "name": "Parent",
  "namespace": "com.example",
  "fields": [{
    "name": "items",
    "type": ["null", {
      "type": "array",
      "items": {
        "type": "record",
        "name": "items",
        "fields": [{"name": "id", "type": "int"}]
      }
    }],
    "default": null
  }]
}`

func main() {
	seen := map[string]int{}
	for i := 0; i < 1000; i++ {
		codec, _ := goavro.NewCodec(schema)
		seen[codec.CanonicalSchema()]++
	}
	fmt.Printf("distinct canonical forms: %d\n", len(seen))
	for k, v := range seen {
		fmt.Printf("  count=%-4d  %s\n", v, k)
	}
}

Output (example run):

distinct canonical forms: 2
  count=862   {"name":"com.example.Parent","type":"record","fields":[{"name":"items","type":["null",{"type":"array","items":{"name":"com.example.items","type":"record","fields":[{"name":"id","type":"int"}]}}]}]}
  count=138   {"name":"com.example.Parent","type":"record","fields":[{"name":"com.example.items","type":["null",{"type":"array","items":{"name":"com.example.items","type":"record","fields":[{"name":"id","type":"int"}]}}]}]}

The difference is the "name" of the items field on the outer Parent record:

  • Correct (~86%): "name":"items" — the field's declared local name
  • Wrong (~14%): "name":"com.example.items" — incorrectly namespace-qualified

Expected behaviour

CanonicalSchema() should always produce the same output for the same input schema. Per the Avro Parsing Canonical Form spec, field names are never namespace-qualified — the second form is incorrect.

Root cause

goavro builds an internal map of named types during schema parsing. Go map iteration is non-deterministic. When computing the canonical form for a field, the code appears to consult this map to resolve whether the field name is a type reference, and non-deterministic iteration occasionally causes the field's local name to be replaced with the fully-qualified type name of the same-named record.

The field "name": "items" collides with the record type com.example.items. When goavro's named-type lookup happens to find that match during canonical form computation, it emits "com.example.items" as the field name instead of "items".

Impact

Any system that uses CanonicalSchema() output as a fingerprint (e.g. to compare schemas between a registry and a stored file) will see spurious mismatches ~14% of the time when this collision exists. We encountered this when validating exported Avro OCF files against a Pub/Sub schema registry — ~22 out of 135 daily files were flagged as mismatched despite containing identical schemas.

Workaround

Rename the record type (or field) so they no longer share a local name. This makes CanonicalSchema() deterministic again.

Environment

  • goavro version: v2.15.0 (latest)
  • Go version: 1.25.4
  • OS: darwin/arm64 (also reproducible on linux/amd64 in CI)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions