Skip to content

Commit f01ea64

Browse files
authored
Fix redefining a named Avro type in a diamond dependency pattern (#2238)
* Fix redefining a named Avro type in a diamond dependency pattern * Fix style * Update changelog
1 parent a0496cf commit f01ea64

5 files changed

Lines changed: 193 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
- Fix URL joining in Python client (#2228)
88
- Handle anyOf/allOf in JSON transforms (#2237)
9+
- Fix redefining a named Avro type in a diamond dependency pattern (#2238)
910

1011

1112
## v2.14.0 - 2026-04-01

src/confluent_kafka/schema_registry/_async/avro.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ async def _resolve_named_schema(
6363
:param schema_registry_client: SchemaRegistryClient to use for retrieval.
6464
:return: named_schemas dict.
6565
"""
66-
named_schemas = {}
66+
named_schemas: Dict[str, AvroSchema] = {}
6767
if schema.references is not None:
6868
for ref in schema.references:
6969
if ref.subject is None or ref.version is None:
@@ -72,18 +72,26 @@ async def _resolve_named_schema(
7272
ref_named_schemas = await _resolve_named_schema(referenced_schema.schema, schema_registry_client)
7373
if referenced_schema.schema.schema_str is None:
7474
raise TypeError("Schema string cannot be None")
75-
76-
parsed_schema = parse_schema_with_repo(referenced_schema.schema.schema_str, named_schemas=ref_named_schemas)
77-
named_schemas.update(ref_named_schemas)
7875
if ref.name is None:
7976
raise TypeError("Name cannot be None")
80-
named_schemas[ref.name] = parsed_schema
77+
named_schemas.update(ref_named_schemas)
78+
# Store the raw (unparsed) schema dict. Pre-parsing here would inline
79+
# any sub-references inside this schema; if the same sub-reference is
80+
# also reachable through another sibling reference (a "diamond"
81+
# dependency), the top-level load_schema would then inject duplicate
82+
# inline definitions and fail with "redefined named type". Keeping
83+
# the raw form lets the top-level load_schema resolve every named
84+
# type exactly once.
85+
raw_schema = json.loads(referenced_schema.schema.schema_str)
86+
named_schemas[ref.name] = raw_schema
8187
# Also store under fully-qualified name so fastavro can resolve
8288
# namespace-qualified type references
83-
if isinstance(parsed_schema, dict) and 'name' in parsed_schema:
84-
fqn = parsed_schema['name']
89+
if isinstance(raw_schema, dict) and 'name' in raw_schema:
90+
ns = raw_schema.get('namespace')
91+
name = raw_schema['name']
92+
fqn = f"{ns}.{name}" if ns and '.' not in name else name
8593
if fqn != ref.name:
86-
named_schemas[fqn] = parsed_schema
94+
named_schemas[fqn] = raw_schema
8795
return named_schemas
8896

8997

src/confluent_kafka/schema_registry/_sync/avro.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def _resolve_named_schema(schema: Schema, schema_registry_client: SchemaRegistry
6060
:param schema_registry_client: SchemaRegistryClient to use for retrieval.
6161
:return: named_schemas dict.
6262
"""
63-
named_schemas = {}
63+
named_schemas: Dict[str, AvroSchema] = {}
6464
if schema.references is not None:
6565
for ref in schema.references:
6666
if ref.subject is None or ref.version is None:
@@ -69,18 +69,26 @@ def _resolve_named_schema(schema: Schema, schema_registry_client: SchemaRegistry
6969
ref_named_schemas = _resolve_named_schema(referenced_schema.schema, schema_registry_client)
7070
if referenced_schema.schema.schema_str is None:
7171
raise TypeError("Schema string cannot be None")
72-
73-
parsed_schema = parse_schema_with_repo(referenced_schema.schema.schema_str, named_schemas=ref_named_schemas)
74-
named_schemas.update(ref_named_schemas)
7572
if ref.name is None:
7673
raise TypeError("Name cannot be None")
77-
named_schemas[ref.name] = parsed_schema
74+
named_schemas.update(ref_named_schemas)
75+
# Store the raw (unparsed) schema dict. Pre-parsing here would inline
76+
# any sub-references inside this schema; if the same sub-reference is
77+
# also reachable through another sibling reference (a "diamond"
78+
# dependency), the top-level load_schema would then inject duplicate
79+
# inline definitions and fail with "redefined named type". Keeping
80+
# the raw form lets the top-level load_schema resolve every named
81+
# type exactly once.
82+
raw_schema = json.loads(referenced_schema.schema.schema_str)
83+
named_schemas[ref.name] = raw_schema
7884
# Also store under fully-qualified name so fastavro can resolve
7985
# namespace-qualified type references
80-
if isinstance(parsed_schema, dict) and 'name' in parsed_schema:
81-
fqn = parsed_schema['name']
86+
if isinstance(raw_schema, dict) and 'name' in raw_schema:
87+
ns = raw_schema.get('namespace')
88+
name = raw_schema['name']
89+
fqn = f"{ns}.{name}" if ns and '.' not in name else name
8290
if fqn != ref.name:
83-
named_schemas[fqn] = parsed_schema
91+
named_schemas[fqn] = raw_schema
8492
return named_schemas
8593

8694

tests/schema_registry/_async/test_avro_serdes.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,86 @@ async def test_avro_serialize_union_with_references():
467467
assert obj == obj2
468468

469469

470+
async def test_avro_diamond_dependency_references():
471+
# Two sibling references (OrderDetails, InvoiceDetails) both depend on the
472+
# same named type (Address). Without the fix in _resolve_named_schema, each
473+
# branch is pre-parsed with Address inlined, and the top-level parse then
474+
# raises SchemaParseException("redefined named type ...Address").
475+
conf = {'url': _BASE_URL}
476+
client = AsyncSchemaRegistryClient.new_client(conf)
477+
ser_conf = {'auto.register.schemas': False, 'use.latest.version': True}
478+
479+
ns = "com.example.diamond"
480+
address_schema = {
481+
'type': 'record',
482+
'name': 'Address',
483+
'namespace': ns,
484+
'fields': [{'name': 'street', 'type': 'string'}],
485+
}
486+
order_schema = {
487+
'type': 'record',
488+
'name': 'OrderDetails',
489+
'namespace': ns,
490+
'fields': [{'name': 'shipping_address', 'type': f'{ns}.Address'}],
491+
}
492+
invoice_schema = {
493+
'type': 'record',
494+
'name': 'InvoiceDetails',
495+
'namespace': ns,
496+
'fields': [{'name': 'billing_address', 'type': f'{ns}.Address'}],
497+
}
498+
root_schema = {
499+
'type': 'record',
500+
'name': 'OrderEvent',
501+
'namespace': ns,
502+
'fields': [
503+
{'name': 'order', 'type': f'{ns}.OrderDetails'},
504+
{'name': 'invoice', 'type': f'{ns}.InvoiceDetails'},
505+
],
506+
}
507+
508+
await client.register_schema('diamond-Address', Schema(json.dumps(address_schema)))
509+
await client.register_schema(
510+
'diamond-OrderDetails',
511+
Schema(
512+
json.dumps(order_schema),
513+
'AVRO',
514+
[SchemaReference(f'{ns}.Address', 'diamond-Address', 1)],
515+
),
516+
)
517+
await client.register_schema(
518+
'diamond-InvoiceDetails',
519+
Schema(
520+
json.dumps(invoice_schema),
521+
'AVRO',
522+
[SchemaReference(f'{ns}.Address', 'diamond-Address', 1)],
523+
),
524+
)
525+
await client.register_schema(
526+
_SUBJECT,
527+
Schema(
528+
json.dumps(root_schema),
529+
'AVRO',
530+
[
531+
SchemaReference(f'{ns}.OrderDetails', 'diamond-OrderDetails', 1),
532+
SchemaReference(f'{ns}.InvoiceDetails', 'diamond-InvoiceDetails', 1),
533+
],
534+
),
535+
)
536+
537+
obj = {
538+
'order': {'shipping_address': {'street': '123 Main St'}},
539+
'invoice': {'billing_address': {'street': '456 Elm St'}},
540+
}
541+
ser = await AsyncAvroSerializer(client, schema_str=None, conf=ser_conf)
542+
ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE)
543+
obj_bytes = await ser(obj, ser_ctx)
544+
545+
deser = await AsyncAvroDeserializer(client)
546+
obj2 = await deser(obj_bytes, ser_ctx)
547+
assert obj == obj2
548+
549+
470550
async def test_avro_schema_evolution():
471551
conf = {'url': _BASE_URL}
472552
client = AsyncSchemaRegistryClient.new_client(conf)

tests/schema_registry/_sync/test_avro_serdes.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,86 @@ def test_avro_serialize_union_with_references():
467467
assert obj == obj2
468468

469469

470+
def test_avro_diamond_dependency_references():
471+
# Two sibling references (OrderDetails, InvoiceDetails) both depend on the
472+
# same named type (Address). Without the fix in _resolve_named_schema, each
473+
# branch is pre-parsed with Address inlined, and the top-level parse then
474+
# raises SchemaParseException("redefined named type ...Address").
475+
conf = {'url': _BASE_URL}
476+
client = SchemaRegistryClient.new_client(conf)
477+
ser_conf = {'auto.register.schemas': False, 'use.latest.version': True}
478+
479+
ns = "com.example.diamond"
480+
address_schema = {
481+
'type': 'record',
482+
'name': 'Address',
483+
'namespace': ns,
484+
'fields': [{'name': 'street', 'type': 'string'}],
485+
}
486+
order_schema = {
487+
'type': 'record',
488+
'name': 'OrderDetails',
489+
'namespace': ns,
490+
'fields': [{'name': 'shipping_address', 'type': f'{ns}.Address'}],
491+
}
492+
invoice_schema = {
493+
'type': 'record',
494+
'name': 'InvoiceDetails',
495+
'namespace': ns,
496+
'fields': [{'name': 'billing_address', 'type': f'{ns}.Address'}],
497+
}
498+
root_schema = {
499+
'type': 'record',
500+
'name': 'OrderEvent',
501+
'namespace': ns,
502+
'fields': [
503+
{'name': 'order', 'type': f'{ns}.OrderDetails'},
504+
{'name': 'invoice', 'type': f'{ns}.InvoiceDetails'},
505+
],
506+
}
507+
508+
client.register_schema('diamond-Address', Schema(json.dumps(address_schema)))
509+
client.register_schema(
510+
'diamond-OrderDetails',
511+
Schema(
512+
json.dumps(order_schema),
513+
'AVRO',
514+
[SchemaReference(f'{ns}.Address', 'diamond-Address', 1)],
515+
),
516+
)
517+
client.register_schema(
518+
'diamond-InvoiceDetails',
519+
Schema(
520+
json.dumps(invoice_schema),
521+
'AVRO',
522+
[SchemaReference(f'{ns}.Address', 'diamond-Address', 1)],
523+
),
524+
)
525+
client.register_schema(
526+
_SUBJECT,
527+
Schema(
528+
json.dumps(root_schema),
529+
'AVRO',
530+
[
531+
SchemaReference(f'{ns}.OrderDetails', 'diamond-OrderDetails', 1),
532+
SchemaReference(f'{ns}.InvoiceDetails', 'diamond-InvoiceDetails', 1),
533+
],
534+
),
535+
)
536+
537+
obj = {
538+
'order': {'shipping_address': {'street': '123 Main St'}},
539+
'invoice': {'billing_address': {'street': '456 Elm St'}},
540+
}
541+
ser = AvroSerializer(client, schema_str=None, conf=ser_conf)
542+
ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE)
543+
obj_bytes = ser(obj, ser_ctx)
544+
545+
deser = AvroDeserializer(client)
546+
obj2 = deser(obj_bytes, ser_ctx)
547+
assert obj == obj2
548+
549+
470550
def test_avro_schema_evolution():
471551
conf = {'url': _BASE_URL}
472552
client = SchemaRegistryClient.new_client(conf)

0 commit comments

Comments
 (0)