Skip to content

Commit 704889a

Browse files
Merge pull request #31047 from bartoszpiekny-redpanda/core-16775-unsupported-feature-diagnostics
cluster_link: unsupported schema-feature policy for SR shadow sync Implements the unsupported_schema_feature_policy config (FAIL default / REMOVE) for API-mode Schema Registry shadowing, so a migration from a Confluent-compatible source handles schema features Redpanda cannot store (rule sets, schema tags, metadata.tags). The tolerant SR client now returns a structured source_schema_read { schema, unsupported[] } — each unsupported_feature carrying a JSON pointer (/ruleSet, /metadata/tags) and JSON type — which the source reader carries through to the reconciler, which applies the configured policy at the single per-node import point. The client opts into extended source fields via the Confluent-Accept-Unknown-Properties header, and a small allow-list of server-assigned fields (guid, ts) is dropped silently. REMOVE imports only the supported projection, increments unsupported_features_removed (surfaced in rpk shadow status and totals), and logs the stripped fields to the broker log. FAIL (the default) treats an unsupported schema as a per-item failure: it logs the offending fields, counts an error, and skips that schema while the rest of the subjects still replicate; the task stays active, and only global errors (source unavailable) fail-fast. Note: FAIL deliberately deviates from the RFC, which describes it as aborting / faulting the whole sync. Per-item handling collects all per-item errors rather than stopping on the first offender and keeps replicating whatever the destination can store — more useful for a migration. The offending subject/version and which fields tripped the policy are written to the broker log (logger shadow_link). Happy to revisit if reviewers prefer whole-sync abort semantics. Note: this covers the schema-fetch path only — the policy acts on per-schema metadata/ruleSet (source_schema_read::unsupported). Config-borne governance features (compatibilityGroup, default/override metadata & rule sets on GET /config) are out of scope: config sync replicates only compatibilityLevel and records the rest as dropped unknown_fields. No proto/model/converter/rpk changes: the config contract, the counter, and rpk shadow status rendering already exist. Tests: reconciler and mirroring-task unit/integration tests cover REMOVE (strip count; no count on a failed import) and FAIL (count + skip the offender, sync the rest, task stays active), plus diagnostics parsing (surfaced fields, ignored guid/ts, Confluent-Accept-Unknown-Properties opt-in).
2 parents 7299281 + ed0c885 commit 704889a

23 files changed

Lines changed: 628 additions & 135 deletions

src/v/cluster_link/schema_registry_sync/BUILD

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ redpanda_cc_library(
7676
"//src/v/ssx:future_util",
7777
"//src/v/ssx:sformat",
7878
"@abseil-cpp//absl/container:flat_hash_set",
79+
"@fmt",
7980
],
8081
visibility = ["//visibility:public"],
8182
deps = [

src/v/cluster_link/schema_registry_sync/http_source_reader.cc

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ http_source_reader::list_subject_versions(
266266
co_return std::move(res.value());
267267
}
268268

269-
ss::future<source_result<ppsr::stored_schema>>
269+
ss::future<source_result<ppsr::source_schema_read>>
270270
http_source_reader::read_subject_version(
271271
ppsr::context_subject sub,
272272
ppsr::schema_version version,
@@ -285,9 +285,11 @@ http_source_reader::read_subject_version(
285285
if (!res.has_value()) {
286286
co_return std::unexpected(to_source_error(std::move(res.error())));
287287
}
288-
// Unmodeled response fields (parsed_schema::unknown_fields) are ignored;
289-
// honoring the unsupported-feature policy is future work.
290-
co_return std::move(res.value().schema);
288+
// Carry the read through unchanged: the schema plus any unsupported fields
289+
// the source served but Redpanda cannot store. The reconciler applies the
290+
// configured unsupported-feature policy to
291+
// `source_schema_read::unsupported`.
292+
co_return std::move(res.value());
291293
}
292294

293295
ss::future<source_result<std::optional<ppsr::mode>>>

src/v/cluster_link/schema_registry_sync/http_source_reader.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ class http_source_reader final : public source_reader {
7777
list_subject_versions(
7878
ppsr::context_subject, ppsr::include_deleted, ss::abort_source&) override;
7979

80-
ss::future<source_result<ppsr::stored_schema>> read_subject_version(
80+
ss::future<source_result<ppsr::source_schema_read>> read_subject_version(
8181
ppsr::context_subject, ppsr::schema_version, ss::abort_source&) override;
8282

8383
ss::future<source_result<std::optional<ppsr::mode>>>

src/v/cluster_link/schema_registry_sync/mirroring_task.cc

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,9 +215,13 @@ model::task_status_report mirroring_task::get_status_report() const {
215215
status.current_sync->summary.subject_versions_changed
216216
+= _reconcile_stats.versions_changed;
217217
status.current_sync->summary.errors += _reconcile_stats.errors;
218+
status.current_sync->summary.unsupported_features_removed
219+
+= _reconcile_stats.unsupported_features_removed;
218220
status.totals_since_task_start.subject_versions_changed
219221
+= _reconcile_stats.versions_changed;
220222
status.totals_since_task_start.errors += _reconcile_stats.errors;
223+
status.totals_since_task_start.unsupported_features_removed
224+
+= _reconcile_stats.unsupported_features_removed;
221225
}
222226
report.detail = model::task_detail{
223227
.schema_registry_sync_status = std::move(status)};
@@ -499,6 +503,7 @@ ss::future<> mirroring_task::list_one_subject(
499503
ss::future<task::state_transition> mirroring_task::full_source_sync(
500504
ss::abort_source& as,
501505
const chunked_hash_set<ppsr::context>& contexts,
506+
model::schema_registry_sync_config::unsupported_feature_policy feature_policy,
502507
const ss::noncopyable_function<bool(const ppsr::context_subject&)>&
503508
in_scope) {
504509
// Cluster-global, so safe to read mid-sync (unlike the per-link config a
@@ -624,7 +629,8 @@ ss::future<task::state_transition> mirroring_task::full_source_sync(
624629
_destination,
625630
[&in_scope](const ppsr::context_subject& cs) { return in_scope(cs); },
626631
_mapper,
627-
limits};
632+
limits,
633+
feature_policy};
628634

629635
// The reconciler increments _reconcile_stats live (reflected mid-sync by
630636
// get_status_report); the fold below moves them into persistent state.
@@ -655,9 +661,13 @@ ss::future<task::state_transition> mirroring_task::full_source_sync(
655661
_status.current_sync->summary.subject_versions_changed
656662
+= stats.versions_changed;
657663
_status.current_sync->summary.errors += stats.errors;
664+
_status.current_sync->summary.unsupported_features_removed
665+
+= stats.unsupported_features_removed;
658666
_status.totals_since_task_start.subject_versions_changed
659667
+= stats.versions_changed;
660668
_status.totals_since_task_start.errors += stats.errors;
669+
_status.totals_since_task_start.unsupported_features_removed
670+
+= stats.unsupported_features_removed;
661671

662672
const auto purged = co_await purge_destination_only_versions(
663673
std::move(to_purge), as);
@@ -826,9 +836,13 @@ mirroring_task::run_impl(ss::abort_source& as) {
826836
// phase shares one mapping without re-reading _config (which a concurrent
827837
// update_config could swap mid-run).
828838
_mapper = context_mapper::make(_config);
839+
// Snapshot the per-link policy while `api` is known non-null. A concurrent
840+
// update_config can swap `_config` across the co_awaits below (and inside
841+
// full_source_sync), so it must not re-read api_mode() after suspending.
842+
const auto feature_policy = api->feature_policy;
829843

830844
co_await refresh_destination_inventory(in_scope, as);
831-
co_return co_await full_source_sync(as, contexts, in_scope);
845+
co_return co_await full_source_sync(as, contexts, feature_policy, in_scope);
832846
}
833847

834848
task::state_transition

src/v/cluster_link/schema_registry_sync/mirroring_task.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ class mirroring_task : public task {
123123
ss::future<state_transition> full_source_sync(
124124
ss::abort_source&,
125125
const chunked_hash_set<ppsr::context>& contexts,
126+
model::schema_registry_sync_config::unsupported_feature_policy
127+
feature_policy,
126128
const ss::noncopyable_function<bool(const ppsr::context_subject&)>&
127129
in_scope);
128130

src/v/cluster_link/schema_registry_sync/reconciler.cc

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
#include <seastar/coroutine/as_future.hh>
2323
#include <seastar/util/defer.hh>
2424

25+
#include <fmt/ranges.h>
26+
2527
#include <algorithm>
2628
#include <array>
2729
#include <chrono>
@@ -110,12 +112,14 @@ reconciler::reconciler(
110112
schema::registry* destination,
111113
ss::noncopyable_function<bool(const ppsr::context_subject&)> in_scope,
112114
const context_mapper& mapper,
113-
limits lim)
115+
limits lim,
116+
model::schema_registry_sync_config::unsupported_feature_policy feature_policy)
114117
: _source(source)
115118
, _destination(destination)
116119
, _in_scope(std::move(in_scope))
117120
, _mapper(&mapper)
118121
, _limits(lim)
122+
, _feature_policy(feature_policy)
119123
, _mem(std::max<size_t>(1, lim.memory_bytes), "schema_registry_sync/memory") {
120124
// Floor the budget so the clamp `min(body_size, memory_bytes)` and the
121125
// semaphore agree; a degenerate 0 admits one over-budget body at a time.
@@ -138,6 +142,7 @@ ss::future<source_result<void>> reconciler::reconcile(
138142
_stats = &stats;
139143
_stats->versions_changed = 0;
140144
_stats->errors = 0;
145+
_stats->unsupported_features_removed = 0;
141146
_outstanding = 0;
142147
_done = false;
143148
_fault.reset();
@@ -289,9 +294,9 @@ reconciler::discover(const ppsr::subject_version& n, ss::abort_source& as) {
289294
co_return source_result<void>{};
290295
}
291296

292-
adjust_units(_mem, units, body_size(fetched.value()));
297+
adjust_units(_mem, units, body_size(fetched.value().schema));
293298

294-
auto refs = resolve_refs(fetched.value());
299+
auto refs = resolve_refs(fetched.value().schema);
295300
chunked_hash_set<ppsr::subject_version> missing;
296301
for (auto& ref : refs) {
297302
if (!_in_scope(ref.sub)) {
@@ -316,6 +321,12 @@ reconciler::discover(const ppsr::subject_version& n, ss::abort_source& as) {
316321
co_return source_result<void>{};
317322
}
318323

324+
// Reject under FAIL before queuing references, so a schema we are going to
325+
// reject does not drag its dependency subtree into the destination.
326+
if (fail_if_contains_unsupported(n, fetched.value().unsupported)) {
327+
co_return source_result<void>{};
328+
}
329+
319330
// The body is released here (fetched goes out of scope): the node will be
320331
// re-fetched once its references complete, leading to it being fetched
321332
// twice in this case; and at most twice on average overall.
@@ -369,18 +380,68 @@ reconciler::do_import(const ppsr::subject_version& n, ss::abort_source& as) {
369380
co_return source_result<void>{};
370381
}
371382

372-
adjust_units(_mem, units, body_size(fetched.value()));
383+
adjust_units(_mem, units, body_size(fetched.value().schema));
373384

374385
co_await import_body(n, std::move(fetched.value()));
375386
co_return source_result<void>{};
376387
}
377388

389+
bool reconciler::fail_if_contains_unsupported(
390+
const ppsr::subject_version& n,
391+
const chunked_vector<ppsr::unsupported_feature>& unsupported) {
392+
if (
393+
_feature_policy
394+
!= model::schema_registry_sync_config::unsupported_feature_policy::fail
395+
|| unsupported.empty()) {
396+
return false;
397+
}
398+
// The FAIL policy refuses to import a schema the destination cannot store
399+
// faithfully, but this is a per-item failure, not a whole-sync abort: count
400+
// it, log the offending fields, and let the rest of the subjects replicate.
401+
vlog(
402+
cllog.warn,
403+
"{}/{} carries {} unsupported schema feature(s) the destination cannot "
404+
"store; failing it under the FAIL policy: {}",
405+
n.sub,
406+
n.version,
407+
unsupported.size(),
408+
fmt::join(unsupported, ", "));
409+
fail(n);
410+
return true;
411+
}
412+
413+
void reconciler::count_if_contains_unsupported_removed(
414+
const ppsr::subject_version& n,
415+
const chunked_vector<ppsr::unsupported_feature>& unsupported) {
416+
if (
417+
_feature_policy
418+
!= model::schema_registry_sync_config::unsupported_feature_policy::
419+
remove
420+
|| unsupported.empty()) {
421+
return;
422+
}
423+
vlog(
424+
cllog.info,
425+
"Removed {} unsupported schema feature(s) from {}/{}: {}",
426+
unsupported.size(),
427+
n.sub,
428+
n.version,
429+
fmt::join(unsupported, ", "));
430+
_stats->unsupported_features_removed += unsupported.size();
431+
}
432+
378433
ss::future<bool> reconciler::import_body(
379-
const ppsr::subject_version& n, ppsr::stored_schema schema) {
434+
const ppsr::subject_version& n, ppsr::source_schema_read read) {
435+
// Authoritative policy gate, next to the REMOVE handling below: every
436+
// import path funnels through here (discover also rejects early, before
437+
// queuing refs).
438+
if (fail_if_contains_unsupported(n, read.unsupported)) {
439+
co_return false;
440+
}
380441
data(n).state = node_state::importing;
381442
// The graph key `n` stays in the source namespace; only the schema written
382443
// to the destination is remapped.
383-
auto remapped = remap_for_import(*_mapper, std::move(schema));
444+
auto remapped = remap_for_import(*_mapper, std::move(read.schema));
384445
if (!remapped.has_value()) {
385446
vlog(
386447
cllog.warn,
@@ -414,6 +475,10 @@ ss::future<bool> reconciler::import_body(
414475
}
415476
data(n).state = node_state::done;
416477
++_stats->versions_changed;
478+
// Account for REMOVE-stripped features only after the projection actually
479+
// lands on the destination, so a failed import does not report features as
480+
// removed. Only read.schema was moved above; read.unsupported is intact.
481+
count_if_contains_unsupported_removed(n, read.unsupported);
417482
wake(n);
418483
co_return true;
419484
}

src/v/cluster_link/schema_registry_sync/reconciler.h

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
#pragma once
1313

14+
#include "cluster_link/model/types.h"
1415
#include "cluster_link/schema_registry_sync/scope.h"
1516
#include "cluster_link/schema_registry_sync/source_reader.h"
1617
#include "container/chunked_hash_map.h"
@@ -37,9 +38,13 @@ struct reconcile_stats {
3738
/// Source versions imported into the destination this run.
3839
uint64_t versions_changed{0};
3940
/// Per-item failures (unresolvable references, import conflicts, unparsable
40-
/// schemas). These do not abort the run; the reconciler counts them and
41-
/// continues.
41+
/// schemas, and -- under the FAIL policy -- source schemas carrying
42+
/// unsupported fields). These do not abort the run; the reconciler counts
43+
/// them and continues so the rest of the subjects still replicate.
4244
uint64_t errors{0};
45+
/// Unsupported fields removed from imported schemas this run under the
46+
/// REMOVE policy.
47+
uint64_t unsupported_features_removed{0};
4348
};
4449

4550
/// The set of changes a reconcile applies: each upsert is a (context, subject,
@@ -97,7 +102,9 @@ class reconciler {
97102
schema::registry* destination,
98103
ss::noncopyable_function<bool(const ppsr::context_subject&)> in_scope,
99104
const context_mapper& mapper,
100-
limits lim);
105+
limits lim,
106+
model::schema_registry_sync_config::unsupported_feature_policy
107+
feature_policy);
101108

102109
/// Imports `work_set.upserts` referent-first.
103110
///
@@ -160,10 +167,35 @@ class reconciler {
160167
ss::future<source_result<void>>
161168
do_import(const ppsr::subject_version& n, ss::abort_source& as);
162169

163-
/// Imports a fetched body, classifying conflicts as per-item failures.
164-
/// Returns true on a successful import.
170+
/// Imports a fetched read, classifying conflicts as per-item failures.
171+
/// Returns true on a successful import. Carries the whole
172+
/// source_schema_read (not just the schema) so the unsupported-feature
173+
/// policy can act on `read.unsupported` at the authoritative per-node
174+
/// import point (FAIL rejection and REMOVE accounting both happen here).
165175
ss::future<bool>
166-
import_body(const ppsr::subject_version& n, ppsr::stored_schema schema);
176+
import_body(const ppsr::subject_version& n, ppsr::source_schema_read read);
177+
178+
/// Under the FAIL policy, treats a schema carrying `unsupported` fields as
179+
/// a per-item failure: logs the offending fields, marks the node (and its
180+
/// dependents) failed via fail(n), and returns true so the caller skips the
181+
/// import. The rest of the sync proceeds -- fail-fast is reserved for
182+
/// global errors like source unavailability. Returns false (a no-op) under
183+
/// REMOVE or when the list is empty.
184+
///
185+
/// Called by `import_body` (authoritative gate) and by `discover` before
186+
/// queuing a node's references.
187+
bool fail_if_contains_unsupported(
188+
const ppsr::subject_version& n,
189+
const chunked_vector<ppsr::unsupported_feature>& unsupported);
190+
191+
/// Under the REMOVE policy, accounts for the `unsupported` fields the
192+
/// parser already dropped from a schema: logs them and adds them to
193+
/// `unsupported_features_removed`. A no-op under FAIL or when the list is
194+
/// empty. Call only after the import succeeds, so a failed import does not
195+
/// report features as removed.
196+
void count_if_contains_unsupported_removed(
197+
const ppsr::subject_version& n,
198+
const chunked_vector<ppsr::unsupported_feature>& unsupported);
167199

168200
/// Marks a node replicated and releases dependents whose in-degree hits 0.
169201
void wake(const ppsr::subject_version& n);
@@ -186,6 +218,8 @@ class reconciler {
186218
ss::noncopyable_function<bool(const ppsr::context_subject&)> _in_scope;
187219
const context_mapper* _mapper;
188220
limits _limits;
221+
model::schema_registry_sync_config::unsupported_feature_policy
222+
_feature_policy;
189223

190224
chunked_hash_set<ppsr::subject_version> _replicated;
191225
chunked_hash_map<ppsr::subject_version, node_data> _nodes;

src/v/cluster_link/schema_registry_sync/source_reader.h

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,11 @@ class source_reader {
7575

7676
/// Reads a specific subject version's schema. The reconcile engine's
7777
/// schema-body fetch path: called for every node it discovers and imports.
78-
virtual ss::future<source_result<ppsr::stored_schema>> read_subject_version(
78+
/// Returns the schema projected into Redpanda's supported model plus any
79+
/// unsupported fields the source carried but Redpanda cannot store, for the
80+
/// caller to apply its unsupported-feature policy.
81+
virtual ss::future<source_result<ppsr::source_schema_read>>
82+
read_subject_version(
7983
ppsr::context_subject, ppsr::schema_version, ss::abort_source&) = 0;
8084

8185
/// Reads the source's own (non-inherited) mode override for a subject or

src/v/cluster_link/schema_registry_sync/tests/http_source_reader_test.cc

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,20 @@ TEST(http_source_reader, read_subject_version_returns_schema) {
170170
.get();
171171
reader.stop().get();
172172

173-
ASSERT_TRUE(res.has_value());
174-
EXPECT_EQ(res->id, pps::schema_id{100001});
175-
EXPECT_EQ(res->version, pps::schema_version{3});
173+
EXPECT_THAT(
174+
res,
175+
Optional(AllOf(
176+
Field(
177+
"schema",
178+
&pps::source_schema_read::schema,
179+
AllOf(
180+
Field("id", &pps::stored_schema::id, pps::schema_id{100001}),
181+
Field(
182+
"version",
183+
&pps::stored_schema::version,
184+
pps::schema_version{3}))),
185+
Field(
186+
"unsupported", &pps::source_schema_read::unsupported, IsEmpty()))));
176187
}
177188

178189
// Discovery and body fetch must request deleted entries so the reconcile can

0 commit comments

Comments
 (0)