redpanda/migrator: avoid O(N²) schema registry fan-out in translate_ids sync - #4734
redpanda/migrator: avoid O(N²) schema registry fan-out in translate_ids sync#4734prakhargarg105 wants to merge 3 commits into
Conversation
…ids sync With translate_ids enabled, each schema was registered at the destination via franz-go's CreateSchema, which resolves the returned ID through SchemaUsagesByID: a fetch of every subject-version sharing that ID, spawned as one unbounded goroutine per usage. Identical schema bodies deduplicate to a single destination ID, so syncing N such subjects cost ~N(N+1)/2 destination requests, none of them bounded by max_parallel_http_requests. Against registries with heavily shared schema bodies this produced request bursts far above the configured concurrency limit (measured: 826 usage reads and peak concurrency 42-79 for 40 subjects with a limit of 2), overloading single-node registries into connection resets. Register with RegisterSchema (one POST, idempotent: returns the existing ID for an already-registered identical schema) plus one LookupSchema to resolve the destination version, making the sync O(N) with concurrency bounded by the worker pool. Measured after: 40 registrations, 0 fan-out reads, peak concurrency exactly at the configured limit. Adds an integration regression test that measures destination traffic through a counting reverse proxy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drop the LookupSchema call after RegisterSchema: the destination version it resolved only fed a log field (schemaInfo's sole functional consumer is the ID), and a transient lookup failure would have aborted an otherwise-successful registration. The translated-ID sync now costs exactly one request per schema. Add the new fan-out integration test to the TESTING.md catalog. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
squiidz
left a comment
There was a problem hiding this comment.
The RegisterSchema swap itself is sound — registration semantics are identical to the old path and this removes the SchemaUsagesByID fan-out for the translate_ids: true arm. A few things though, the first one I'd consider blocking since the PR title doesn't hold for the default config (inline comments below).
Two points that fall outside the diff:
The root cause lives in our own wrapper, and two other components still hit it. sr.Client.CreateSchema and CreateSchemaWithIDAndVersion (internal/impl/confluent/sr/client.go:167,181) pay the full SchemaUsagesByID fan-out only to return ss.ID. The schema_registry output (internal/impl/kafka/output_schema_registry.go:478,503 — which has its own translate_ids mode, hit per source subject-version) and the SR encode processor (internal/impl/confluent/processor_schema_registry_encode.go:653, auto-registration on cache miss) call them with the exact same O(N²) behaviour. Switching the two wrapper methods to RegisterSchema is a two-line change that fixes every caller at once — worth doing here or as an immediate follow-up.
max_parallel_http_requests is only enforced as a Sync worker count, not at the HTTP layer, so any other franz-go internal fan-out (e.g. SchemaReferences, the batch endpoints) — or a future library upgrade — silently re-breaks the invariant exactly as CreateSchema did. A semaphore RoundTripper wrapping the client transport with MaxParallelHTTPRequests tokens would make the configured limit unconditionally true, and demote this PR's swap to a request-count optimization rather than the sole enforcement.
| @@ -797,14 +797,30 @@ func (m *schemaRegistryMigrator) syncSubjectSchema(ctx context.Context, ss sr.Su | |||
| var info schemaInfo | |||
| t0 := time.Now() | |||
| if m.conf.TranslateIDs { | |||
There was a problem hiding this comment.
The else branch below (translate_ids: false, the default) still calls CreateSchemaWithIDAndVersion, which performs the exact same SchemaUsagesByID fan-out this PR removes — so the O(N²) behaviour remains for the common configuration.
The fix is even simpler there than here: id and version are inputs (ss.ID, ss.Version), so RegisterSchema alone suffices and info can be built directly as {dstSubject, ss.Version, ss.ID} with no lookup at all. The new fan-out test only covers translate_ids: true, so this path is also untested.
| type countingProxy struct { | ||
| server *httptest.Server | ||
|
|
||
| total atomic.Int64 // all requests |
There was a problem hiding this comment.
total is incremented on every request but never read by any assertion or log — either drop it, or better, use it for the missing traffic-happened assertion (see the comment on the assertions below).
…den test Extend the RegisterSchema swap to the translate_ids: false branch (the default configuration), where CreateSchemaWithIDAndVersion performed the same unbounded SchemaUsagesByID fan-out; the ID and version are inputs there, so no lookup is needed at all. The redpanda#26331 fallback is preserved unchanged. Harden the fan-out regression test per review: cover both ID-translation modes (table-driven, IMPORT destination for fixed IDs), seed the source via RegisterSchema so setup does not itself fan out, guard against a vacuous pass by requiring one registration per subject, tighten the fan-out bounds to exactly zero usage-endpoint requests, use the package's standard sync timeout, and fix the new file's copyright year. Remove the now-unused schemaInfoFromSubjectSchema. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Problem
With
schema_registry.translate_ids: true, theredpanda_migratoroutput registers each schema at the destination via franz-go'sCreateSchema, which resolves the returned ID throughSchemaUsagesByID— a fetch of every subject-version sharing that ID, spawned as one unbounded goroutine per usage.Identical schema bodies deduplicate to a single destination ID, so syncing N such subjects costs ~N(N+1)/2 destination requests instead of N, and none of those requests are bounded by
max_parallel_http_requests. Against a registry with heavily shared schema bodies (e.g. per-environment copies of the same schemas) this produces sustained request bursts that overload single-node registries intoconnection reset by peer, which fails the sync, which fails the output connect, which restarts the pipeline and replays the whole quadratic sweep — observed in a production migration as 169 consecutive connect failures with zero topics migrated.A side effect of the fan-out is actively misleading errors: the failing GET names an unrelated subject that merely shares the schema ID (
sync subject schema <A> ...: unable to GET ".../subjects/<B>/versions/1"), making healthy subjects look broken.Fix
In the
translate_idsbranch ofsyncSubjectSchema, replaceCreateSchemawith:RegisterSchema— one POST; idempotent (returns the existing ID for an already-registered identical schema), preserving the previous create-or-reuse semantics;LookupSchema— to resolve the destination version forschemaInfoand logging.Two sequential requests per schema inside the bounded worker pool: O(N) total, concurrency capped by
max_parallel_http_requests. franz-go's ownRegisterSchemadocs recommend exactly this trade. Thetranslate_ids: falsepath is unchanged.Measurements
From the included regression test (40 subjects sharing one schema body,
max_parallel_http_requests: 2, counting reverse proxy in front of the destination registry):GET /subjects/S/versions/V)Validation
TestIntegrationSchemaRegistryMigratorSyncSharedSchemaFanoutfails on the previous code and passes with the fix.TestIntegrationSchemaRegistryMigrator*integration tests pass exceptSyncWithReferences, which fails identically on unmodified main (pre-existing, unrelated).go vet,gofumptclean.Follow-ups (not in this PR)
The same fan-out pattern exists in:
translate_ids: falsebranch (CreateSchemaWithIDAndVersionfans out internally) — fixable withRegisterSchema(ctx, subject, sch, ss.ID, ss.Version), no lookup needed;internal/impl/confluent/srwrapper (CreateSchema/CreateSchemaWithIDAndVersionboth discard everything but the ID), which the standaloneschema_registryoutput andschema_registry_encodeprocessor inherit;🤖 Generated with Claude Code