Skip to content

Enforce association lifecycle invariants - #4460

Open
Robert Yokota (rayokota) wants to merge 2 commits into
8.0.xfrom
assoc-bugs
Open

Enforce association lifecycle invariants#4460
Robert Yokota (rayokota) wants to merge 2 commits into
8.0.xfrom
assoc-bugs

Conversation

@rayokota

@rayokota Robert Yokota (rayokota) commented Jul 28, 2026

Copy link
Copy Markdown
Member

What

Fixes DGS-24501, DGS-24537, DGS-24538, DGS-24539, DGS-24582, DGS-24631, DGS-24646

Seven association bugs reduce to three invariants that weren't being enforced. Each is
fixed at the point the invariant belongs rather than at the symptom.

Invariant 1 — an upsert may not create a topic-owned (STRONG) association

A STRONG association is owned by its topic and can only be created together with the
topic. Previously an upsert could create one, and would promote to STRONG on its own
whenever a schema was supplied.

  • DGS-24537IncrementalAlterConfigs created a topic-owned association on a
    vanilla topic. Now rejected.
  • DGS-24646SET key={subject, schema} silently created a STRONG association the
    caller never asked for. Now rejected.
  • DGS-24538 — that silent promotion then collided with the subject's existing WEAK
    associations and surfaced as An association already exists for subject '...', which
    is not the real reason. The request now fails on its actual cause.

Enforced in two places: the schema half in applyDefaults on the request entities, the
lifecycle half in AbstractSchemaRegistry where the subject's mode is known.
Subjects in IMPORT mode are exempt, so cluster linking can still replicate an
established frozen STRONG association to a destination.

Evolving the schema of an association that already exists — the common alter-configs
flow — is unaffected, since these defaults only apply while creating.

Invariant 2 — a subject in IMPORT mode receives its versions by replication

  • DGS-24631 — a schema passed alongside an association was silently dropped, so the
    call reported success having registered nothing. Now rejected.
  • DGS-24582 — the association was created against a subject that didn't exist yet.
    The existing "subject must have an active version" guard was correct but was skipped
    whenever a schema was supplied; with a schema no longer accepted during IMPORT, that
    guard applies and the association is refused until the subject's versions land.

Invariant 3 — a resource has one lifecycle across all association types

A topic is either topic-owned (STRONG) or shared (WEAK), never a mix.

  • DGS-24501CreateAssociation accepted a shared key plus a topic-owned value in
    one request.
  • DGS-24539IncrementalAlterConfigs added a shared association to a topic that
    already had a topic-owned one.

checkUniformLifecycle computes the lifecycle the resource would end up with per type
— from the request where it supplies one, from the existing association where it does
not, and from storage for types the request leaves alone — and rejects anything that
leaves the resource mixed. Converting every type in one request is therefore allowed.

Two details that make this work:

  • The pre-existing lookup is narrowed to the association types named in the request, so
    a sibling type is invisible to it. The check re-queries all types, which is what makes
    DGS-24539 detectable.
  • At the validate phase the caller may not yet have a resourceId, so the resource is
    matched by (name, namespace). Several resources can share those — an orphan from a
    deleted topic beside a live one — so mostRecentlyUpdatedResource narrows to one
    resource before evaluating. Merging per type instead would stitch types across
    resources and report a mix that never existed. The pre-existing by-name fallback now
    goes through the same helper, so the equivalence, subject-change and frozen checks all
    judge the same resource.

Behavior changes

  • Creating an association through IncrementalAlterConfigs while passing a schema now
    fails; topic-owned associations must be created with the topic.
  • An upsert that creates an association and explicitly asks for STRONG now fails outside
    IMPORT mode.
  • Passing a schema while the subject is in IMPORT mode now errors instead of being
    ignored.
  • A resource holding mixed lifecycles rejects any update that leaves it mixed.

Checklist

Please answer the questions with Y, N or N/A if not applicable.

  • [Y] Contains customer facing changes? Including API/behavior changes
  • [N] Is this change gated behind config(s)?
    • List the config(s) needed to be set to enable this change
  • [Y] Did you add sufficient unit test and/or integration test coverage for this PR?
    • If not, please explain why it is not required
  • [N] Does this change require modifying existing system tests or adding new system tests?
    • If so, include tracking information for the system test changes
  • [N] Must this be released together with other change(s), either in this repo or another one?
    • If so, please include the link(s) to the changes that must be released together

References

JIRA:

Test & Review

Open questions / Follow-ups

@rayokota
Robert Yokota (rayokota) requested a review from a team as a code owner July 28, 2026 00:36
@confluent-cla-assistant

Copy link
Copy Markdown

🎉 All Contributor License Agreements have been signed. Ready to merge.
Please push an empty commit if you would like to re-run the checks to verify CLA status for all contributors.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR enforces association lifecycle invariants in Schema Registry so that associations cannot end up in invalid STRONG/WEAK combinations, and so IMPORT-mode subjects cannot receive schemas via association requests (preventing “successful” no-op schema drops).

Changes:

  • Prevent upsert from creating topic-owned (STRONG) associations (with an IMPORT-mode exemption for replication use-cases).
  • Reject schemas provided alongside associations when the subject is in IMPORT mode (instead of silently dropping them).
  • Enforce uniform lifecycle per resource across association types (reject mixed STRONG/WEAK, while allowing “convert all types together” repairs).

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.

Show a summary per file
File Description
core/src/main/java/io/confluent/kafka/schemaregistry/storage/AbstractSchemaRegistry.java Adds server-side enforcement for (1) “upsert can’t create STRONG”, (2) “no schema in IMPORT”, and (3) uniform per-resource lifecycle across association types.
core/src/test/java/io/confluent/kafka/schemaregistry/rest/RestApiAssociationTest.java Adds/updates REST-level integration tests covering mixed-lifecycle rejection, upsert-creation restrictions, and IMPORT-mode schema rejection.
client/src/main/java/io/confluent/kafka/schemaregistry/client/rest/entities/requests/AssociationCreateOrUpdateInfo.java Updates defaulting rules to reject schema on upsert-creation paths (schema implies STRONG/topic-owned).
client/src/main/java/io/confluent/kafka/schemaregistry/client/rest/entities/requests/AssociationCreateOrUpdateOp.java Mirrors the upsert-creation schema rejection/defaulting behavior for batch operation entities.
client/src/test/java/io/confluent/kafka/schemaregistry/client/rest/entities/requests/AssociationsRequestTest.java Adds unit tests validating the new defaulting/validation behavior for create vs upsert-creation scenarios.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 8 changed files in this pull request and generated 1 comment.

Files not reviewed (2)
  • core/src/test/java/io/confluent/kafka/serializers/protobuf/test/Ref.java: Generated file
  • core/src/test/java/io/confluent/kafka/serializers/protobuf/test/Root.java: Generated file

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

core/src/main/java/io/confluent/kafka/schemaregistry/storage/AbstractSchemaRegistry.java:2057

  • In the dry-run create-without-resourceId fallback, the resource selection is based only on association types present in the request. If the most-recent resource sharing (name, namespace, type) does not yet have those types, it will be invisible to the query and an older (e.g., orphaned) resource can be selected instead, causing equivalence / frozen / subject-change checks to compare against the wrong resource. Consider selecting the most-recent resource using all association types first, then filtering to the requested types for the per-type map.
      List<Association> fallback = getAssociationsByResourceName(
          request.getResourceName(), request.getResourceNamespace(),
          request.getResourceType(), new ArrayList<>(infosByType.keySet()), null);
      // The fallback can return entries from multiple resourceIds sharing (name, namespace,
      // type) — e.g. an orphan from a deleted topic alongside a live one. Narrow to a single

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

core/src/main/java/io/confluent/kafka/schemaregistry/storage/AbstractSchemaRegistry.java:2555

  • The comment says a run ends at a repeat of an association type, but the loop only breaks on op-type changes. Since AssociationOpRequest#validate now rejects repeated association types within a request, this comment is misleading and should be updated to match the actual behavior.
          // A run ends at a change of op type, and also at a repeat of an association type:
          // a request may only carry one entry per type, and successive ops on the same type
          // are meant to apply one after another rather than collapse into a single request.

@sonarqube-confluent

Copy link
Copy Markdown

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants