Skip to content

Commit 8c59496

Browse files
committed
datalake: add e2e test for per-topic schema registry context
Add a ducktape integration test verifying the full end-to-end path for the `redpanda.schema.registry.context` topic property: SR schema registration in contexts, topic property configuration, translator schema resolution, and typed Iceberg columns. test_context_isolation: two topics bound to different SR contexts resolve different schemas from the same numeric schema ID, producing different Iceberg table column layouts. test_wrong_context_dlq: schema ID not present in the configured context sends records to the dead-letter-queue table.
1 parent 99e704f commit 8c59496

3 files changed

Lines changed: 239 additions & 0 deletions

File tree

tests/rptest/clients/types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ class TopicSpec:
4949
PROPERTY_ICEBERG_INVALID_RECORD_ACTION = "redpanda.iceberg.invalid.record.action"
5050
PROPERTY_ICEBERG_TARGET_LAG_MS = "redpanda.iceberg.target.lag.ms"
5151
PROPERTY_ICEBERG_PARTITION_SPEC = "redpanda.iceberg.partition.spec"
52+
PROPERTY_SCHEMA_REGISTRY_CONTEXT = "redpanda.schema.registry.context"
5253
PROPERTY_MIN_CLEANABLE_DIRTY_RATIO = "min.cleanable.dirty.ratio"
5354
PROPERTY_MIN_COMPACTION_LAG_MS = "min.compaction.lag.ms"
5455
PROPERTY_MAX_COMPACTION_LAG_MS = "max.compaction.lag.ms"
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
# Copyright 2025 Redpanda Data, Inc.
2+
#
3+
# Use of this software is governed by the Business Source License
4+
# included in the file licenses/BSL.md
5+
#
6+
# As of the Change Date specified in that file, in accordance with
7+
# the Business Source License, use of this software will be governed
8+
# by the Apache License, Version 2.0
9+
10+
import io
11+
import json
12+
import struct
13+
14+
import avro.io
15+
import avro.schema
16+
import confluent_kafka
17+
import requests
18+
from ducktape.mark import matrix
19+
20+
from rptest.clients.types import TopicSpec
21+
from rptest.services.cluster import cluster
22+
from rptest.services.redpanda import SISettings, SchemaRegistryConfig
23+
from rptest.tests.datalake.catalog_service_factory import (
24+
filesystem_catalog_type,
25+
)
26+
from rptest.tests.datalake.datalake_services import DatalakeServices
27+
from rptest.tests.datalake.query_engine_base import QueryEngineType
28+
from rptest.tests.datalake.utils import supported_storage_types
29+
from rptest.tests.redpanda_test import RedpandaTest
30+
31+
SCHEMA_A = {
32+
"type": "record",
33+
"name": "RecordA",
34+
"fields": [
35+
{"name": "name", "type": "string"},
36+
{"name": "age", "type": "int"},
37+
],
38+
}
39+
40+
SCHEMA_B = {
41+
"type": "record",
42+
"name": "RecordB",
43+
"fields": [
44+
{"name": "color", "type": "string"},
45+
{"name": "size", "type": "double"},
46+
],
47+
}
48+
49+
SCHEMA_C = {
50+
"type": "record",
51+
"name": "RecordC",
52+
"fields": [
53+
{"name": "x", "type": "int"},
54+
],
55+
}
56+
57+
58+
class DatalakeSchemaRegistryContextTest(RedpandaTest):
59+
def __init__(self, test_context):
60+
super().__init__(
61+
test_context=test_context,
62+
num_brokers=1,
63+
extra_rp_conf={
64+
"iceberg_enabled": True,
65+
"iceberg_catalog_commit_interval_ms": 5000,
66+
"schema_registry_enable_qualified_subjects": True,
67+
},
68+
schema_registry_config=SchemaRegistryConfig(),
69+
si_settings=SISettings(test_context=test_context),
70+
)
71+
72+
def setUp(self):
73+
# DatalakeServices starts Redpanda.
74+
pass
75+
76+
def _sr_url(self):
77+
return self.redpanda.schema_reg().split(",")[0]
78+
79+
def _register_schema(self, context, subject, schema_dict):
80+
"""Register an Avro schema in the given SR context. Returns the
81+
assigned schema ID."""
82+
url = f"{self._sr_url()}/subjects/:.{context}:{subject}/versions"
83+
resp = requests.post(
84+
url,
85+
headers={"Content-Type": "application/vnd.schemaregistry.v1+json"},
86+
json={"schema": json.dumps(schema_dict), "schemaType": "AVRO"},
87+
)
88+
resp.raise_for_status()
89+
return resp.json()["id"]
90+
91+
def _make_confluent_record(self, schema_id, schema_dict, record):
92+
"""Build a Confluent wire-format payload: magic byte + 4-byte
93+
schema ID + Avro binary-encoded record."""
94+
parsed = avro.schema.parse(json.dumps(schema_dict))
95+
buf = io.BytesIO()
96+
buf.write(struct.pack(">bI", 0, schema_id))
97+
encoder = avro.io.BinaryEncoder(buf)
98+
writer = avro.io.DatumWriter(parsed)
99+
writer.write(record, encoder)
100+
return buf.getvalue()
101+
102+
def _produce_confluent_records(self, topic, schema_id, schema_dict, records):
103+
"""Produce raw Confluent wire-format records via confluent_kafka."""
104+
producer = confluent_kafka.Producer(
105+
{"bootstrap.servers": self.redpanda.brokers()}
106+
)
107+
for record in records:
108+
payload = self._make_confluent_record(schema_id, schema_dict, record)
109+
producer.produce(topic, value=payload)
110+
producer.flush()
111+
112+
@cluster(num_nodes=3)
113+
@matrix(cloud_storage_type=supported_storage_types())
114+
def test_context_isolation(self, cloud_storage_type):
115+
"""Two topics bound to different SR contexts resolve different
116+
schemas from the same numeric schema ID, producing different
117+
Iceberg column layouts."""
118+
119+
with DatalakeServices(
120+
self.test_context,
121+
redpanda=self.redpanda,
122+
catalog_type=filesystem_catalog_type(),
123+
include_query_engines=[QueryEngineType.SPARK],
124+
) as dl:
125+
# Register schemas in separate contexts.
126+
id_a = self._register_schema("ctx1", "topic_a-value", SCHEMA_A)
127+
id_b = self._register_schema("ctx2", "topic_b-value", SCHEMA_B)
128+
self.logger.info(
129+
f"Registered schema A (id={id_a}) in .ctx1, "
130+
f"schema B (id={id_b}) in .ctx2"
131+
)
132+
# SR assigns IDs per-context starting at 1, so both should
133+
# get the same numeric ID. This is what makes the test
134+
# meaningful: it proves that the translator resolves the
135+
# correct schema via (context, id) rather than just id.
136+
# May become brittle if SR changes to global ID allocation.
137+
assert id_a == id_b, (
138+
f"Expected same schema ID in both contexts, "
139+
f"got id_a={id_a}, id_b={id_b}"
140+
)
141+
142+
# Create topics with per-topic SR context.
143+
dl.create_iceberg_enabled_topic(
144+
"topic_a",
145+
iceberg_mode="value_schema_id_prefix",
146+
config={
147+
TopicSpec.PROPERTY_SCHEMA_REGISTRY_CONTEXT: ".ctx1",
148+
},
149+
)
150+
dl.create_iceberg_enabled_topic(
151+
"topic_b",
152+
iceberg_mode="value_schema_id_prefix",
153+
config={
154+
TopicSpec.PROPERTY_SCHEMA_REGISTRY_CONTEXT: ".ctx2",
155+
},
156+
)
157+
158+
# Produce records.
159+
records_a = [{"name": f"user_{i}", "age": 20 + i} for i in range(10)]
160+
records_b = [{"color": f"color_{i}", "size": float(i)} for i in range(10)]
161+
162+
self._produce_confluent_records("topic_a", id_a, SCHEMA_A, records_a)
163+
self._produce_confluent_records("topic_b", id_b, SCHEMA_B, records_b)
164+
165+
# Wait for translation.
166+
dl.wait_for_translation("topic_a", msg_count=10)
167+
dl.wait_for_translation("topic_b", msg_count=10)
168+
169+
# Verify Iceberg table columns.
170+
spark = dl.spark()
171+
172+
desc_a = spark.run_query_fetch_all("describe redpanda.topic_a")
173+
# Spark describe returns header row at [0] and partition info
174+
# in the last 3 rows; strip those.
175+
cols_a = {(r[0], r[1]) for r in desc_a[1:-3]}
176+
assert ("name", "string") in cols_a, (
177+
f"Expected 'name' string column in topic_a, got {cols_a}"
178+
)
179+
assert ("age", "int") in cols_a, (
180+
f"Expected 'age' int column in topic_a, got {cols_a}"
181+
)
182+
183+
desc_b = spark.run_query_fetch_all("describe redpanda.topic_b")
184+
cols_b = {(r[0], r[1]) for r in desc_b[1:-3]}
185+
assert ("color", "string") in cols_b, (
186+
f"Expected 'color' string column in topic_b, got {cols_b}"
187+
)
188+
assert ("size", "double") in cols_b, (
189+
f"Expected 'size' double column in topic_b, got {cols_b}"
190+
)
191+
192+
# Negative: topic_a should NOT have topic_b's columns.
193+
assert ("color", "string") not in cols_a, (
194+
"topic_a should not have topic_b's 'color' column"
195+
)
196+
assert ("name", "string") not in cols_b, (
197+
"topic_b should not have topic_a's 'name' column"
198+
)
199+
200+
@cluster(num_nodes=3)
201+
@matrix(cloud_storage_type=supported_storage_types())
202+
def test_wrong_context_dlq(self, cloud_storage_type):
203+
"""Schema ID not present in the configured context sends records
204+
to the dead-letter-queue table."""
205+
206+
with DatalakeServices(
207+
self.test_context,
208+
redpanda=self.redpanda,
209+
catalog_type=filesystem_catalog_type(),
210+
include_query_engines=[QueryEngineType.SPARK],
211+
) as dl:
212+
# Register schema in .ctx1 only.
213+
schema_id = self._register_schema("ctx1", "topic_c-value", SCHEMA_C)
214+
self.logger.info(f"Registered schema C (id={schema_id}) in .ctx1")
215+
216+
# Create topic pointing to a context where the schema does
217+
# not exist.
218+
dl.create_iceberg_enabled_topic(
219+
"topic_c",
220+
iceberg_mode="value_schema_id_prefix",
221+
config={
222+
TopicSpec.PROPERTY_SCHEMA_REGISTRY_CONTEXT: ".wrong",
223+
TopicSpec.PROPERTY_ICEBERG_INVALID_RECORD_ACTION: "dlq_table",
224+
},
225+
)
226+
227+
# Produce records — translator will fail to resolve schema_id
228+
# in .wrong context.
229+
records = [{"x": i} for i in range(10)]
230+
self._produce_confluent_records("topic_c", schema_id, SCHEMA_C, records)
231+
232+
# Records should land in the DLQ table.
233+
dl.wait_for_translation(
234+
"topic_c",
235+
msg_count=10,
236+
table_override="topic_c~dlq",
237+
)

tools/type-checking/type-check-strictness.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@
128128
"rptest/tests/datalake/protobuf_json_test.py",
129129
"rptest/tests/datalake/recovery_mode_test.py",
130130
"rptest/tests/datalake/rest_catalog_connection_test.py",
131+
"rptest/tests/datalake/schema_registry_context_test.py",
131132
"rptest/tests/datalake/simple_connect_test.py",
132133
"rptest/tests/datalake/spark_sql_server_smoke_test.py",
133134
"rptest/tests/datalake/throttling_test.py",

0 commit comments

Comments
 (0)