8000 feat(inkless:controller): add the SetClassicPrefixInRemoteStorage RPC [KC-582] by jeqo · Pull Request #827 · aiven/inkless · GitHub
[go: up one dir, main page]

Skip to content

feat(inkless:controller): add the SetClassicPrefixInRemoteStorage RPC [KC-582] - #827

Merged
viktorsomogyi merged 3 commits into
mainfrom
jeqo/kc-582-controller-handling
Oct 16, 2026
Merged

viktorsomogyi merged 3 commits into
mainfrom
jeqo/kc-582-controller-handling

Conversation

@jeqo
@jeqo jeqo commented Oct 14, 2026 •
Copy link
Copy Markdown
Contributor

Phase 2 of the classic-to-diskless switch starts when the controller sets classicPrefixInRemoteStorage (tag 103, added in #824) on a partition. This PR adds the controller side: a broker-to-controller RPC that the partition leader sends once it proves that the classic prefix below the committed seal is in remote storage, and the controller commit that sets the flag. Nothing sends the RPC yet; the broker side follows in KC-588.

The commit goes through PartitionChangeBuilder, not through a raw record like the seal. The same record sets the ISR to the active assigned replicas, so it can complete a reassignment and elect a new leader.

Commits

  • chore(inkless): mark ReplicationControlManager and PartitionChangeBuilder as interleaved upstream files in INKLESS_OWNERSHIP.
  • feat(inkless:controller): add the switch flag to PartitionChangeBuilder. The setter adds tag 103, forces a leader epoch increase when nothing else changes the leader, and writes tag 102.
  • feat(inkless:controller): add the SetClassicPrefixInRemoteStorage RPC (API key 503, controller listener only, CLUSTER_ACTION), the controller checks, the entry points, and the flag in the DescribeTopicPartitions response and the topic switch tool output.

See the commit messages for the reasoning behind each step, including the order of the controller checks.

Operator impact

  • kafka-topic-switch.sh state prints classicPrefixInRemoteStorage for each partition. When the seal is committed, it adds the phase: false (phase 1) or true (phase 2).
  • The DescribeTopicPartitions response carries tag 103 only for partitions where the flag is set.

Testing

  • PartitionChangeBuilderTest: the flag alone increases the leader epoch once and keeps the leader; an election or a reassignment that removes the leader keeps the new leader; tag 102 equals the new leader epoch, also when it replaces an earlier diskless leader epoch, and the seal stays unchanged.
  • ReplicationControlManagerInklessTest (SetClassicPrefixInRemoteStorageTests, 19 cases): success; a retry after the commit with the old leader epoch succeeds without a record; a different seal after the commit, a seal mismatch, a pending seal, and no seal get INVALID_REQUEST; epoch and leader checks; a fenced leader, a leader in controlled shutdown, and a leaderless partition never reach the builder; the commit adds active replicas to the ISR and keeps fenced ones out; a commit that completes a reassignment elects a new leader; one bad partition in a request does not fail the others.
  • QuorumControllerTest.testSetClassicPrefixInRemoteStorageCommitsFlag: the commit and a retry through a real QuorumController write event.
  • ControllerApisTest: authorization (CLUSTER_ACTION) and NOT_CONTROLLER for every partition when the controller future fails.
  • DescribeTopicPartitionsRequestHandlerTest and TopicSwitchCommandTest: the flag in the describe response and the phase in the tool output.
  • RequestResponseTest: serialization of the new request and response.

Diskless leader epoch

This PR moves the point where the controller sets the diskless leader epoch (tag 102), from the seal commit to the flag commit. The flag commit writes tag 102 equal to the leader epoch that the record produces. No leader used that epoch before the flag, so it is greater than every classic and phase 1 epoch.

The seal commit sets tag 102 to the leader epoch at the seal. That value can be lower than a phase 1 epoch: if the leader changes while the seal is pending, the new leader adds an epoch cache entry at the seal with a higher epoch. The first consolidated batch carries the lower label, so appending it removes that entry from the epoch cache.

The seal commit still writes tag 102, because tod 8000 ay consolidation starts after the seal commit, in phase 1, and stamps batches with that value. If the flag commit overwrote it while those batches exist, the diskless leader endpoint would answer their epoch with the seal, and followers would truncate [seal, LEO). This can't happen yet: nothing sends the RPC until KC-588. KC-589 gates consolidation on the flag and stops the seal commit from writing tag 102, so no batch carries a label from before the flag. KC-588 and KC-589 ship together for this reason.

Follow-ups

  • KC-588: the leader detects the upload and sends the RPC, with a cluster test for the whole flow. The broker starts phase 2 from the metadata record, not from the response.
  • KC-586: reject every seal request after the flag, and stop the forced seal from writing tag 102. On a switching topic, a reassignment takes the diskless path and finishes at once, so the reassignment path needs the gate too.
  • KC-589: gate consolidation on the flag, and stop writing tag 102 at the seal commit. It ships together with KC-588.

ReplicationControlManager carries the diskless placement, election, and
classic-to-diskless switch commits. PartitionChangeBuilder gets the switch
flag commit. Both are upstream files with Inkless edits, so they need a
manual check on every upstream sync.

Assisted-by: Claude Opus 5.5
@jeqo
jeqo requested a balanced review from Copilot October 14, 2026 19:17
Copilot AI left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Metadata tag ordering prevents flag commits, and duplicate request entries produce inconsistent epoch records.

2 open findings
What changed in this PR

Adds the controller-side RPC for phase 2 of Inkless’s classic-to-diskless switch. Broker-side detection and sending remain follow-up work.

Changes:

  • Adds RPC schemas, authorization, dispatch, and controller validation.
  • Commits the flag, ISR updates, and diskless leader epoch together.
  • Exposes switch progress through partition descriptions and the switch tool.
File Description
tools/​src/​test/​java/​org/​apache/​kafka/​tools/​TopicSwitchCommandTest.java Tests flag and phase output.
tools/​src/​main/​java/​org/​apache/​kafka/​tools/​TopicSwitchCommand.java Displays switch phases.
test-common/​test-common-runtime/​src/​main/​java/​org/​apache/​kafka/​common/​test/​MockController.java Adds controller interface stub.
server/​src/​main/​java/​org/​apache/​kafka/​network/​RequestConvertToJson.java Adds RPC JSON conversion.
metadata/​src/​test/​java/​org/​apache/​kafka/​controller/​ReplicationControlManagerInklessTest.java Tests validation and state transitions.
metadata/​src/​test/​java/​org/​apache/​kafka/​controller/​QuorumControllerTest.java Tests commit and retry handling.
metadata/​src/​test/​java/​org/​apache/​kafka/​controller/​PartitionChangeBuilderTest.java Tests flag and epoch records.
metadata/​src/​main/​java/​org/​apache/​kafka/​metadata/​KRaftMetadataCache.java Exposes the flag in descriptions.
metadata/​src/​main/​java/​org/​apache/​kafka/​controller/​ReplicationControlManager.java Validates requests and builds changes.
metadata/​src/​main/​java/​org/​apache/​kafka/​controller/​QuorumController.java Schedules controller writes.
metadata/​src/​main/​java/​org/​apache/​kafka/​controller/​PartitionChangeBuilder.java Combines flag and leader-epoch changes.
metadata/​src/​main/​java/​org/​apache/​kafka/​controller/​Controller.java Declares the controller operation.
INKLESS_OWNERSHIP Records shared controller-file ownership.
core/​src/​test/​scala/​unit/​kafka/​server/​ControllerApisTest.scala Tests authorization and failure responses.
core/​src/​test/​java/​kafka/​server/​handlers/​DescribeTopicPartitionsRequestHandlerTest.java Tests flag presence and omission.
core/​src/​main/​scala/​kafka/​server/​ControllerApis.scala Authorizes and dispatches requests.
clients/​src/​test/​java/​org/​apache/​kafka/​common/​requests/​RequestResponseTest.java Adds RPC serialization coverage.
clients/​src/​main/​resources/​common/​message/​SetClassicPrefixInRemoteStorageResponse.json Defines response schema.
clients/​src/​main/​resources/​common/​message/​SetClassicPrefixInRemoteStorageRequest.json Defines controller-only request schema.
clients/​src/​main/​java/​org/​apache/​kafka/​common/​requests/​SetClassicPrefixInRemoteStorageResponse.java Implements response wrapper.
clients/​src/​main/​java/​org/​apache/​kafka/​common/​requests/​SetClassicPrefixInRemoteStorageRequest.java Implements request and error responses.
clients/​src/​main/​java/​org/​apache/​kafka/​common/​requests/​AbstractResponse.java Registers response parsing.
clients/​src/​main/​java/​org/​apache/​kafka/​common/​requests/​AbstractRequest.java Registers request parsing.
clients/​src/​main/​java/​org/​apache/​kafka/​common/​protocol/​ApiKeys.java Registers API key 503.
clients/​src/​main/​java/​org/​apache/​kafka/​clients/​admin/​Admin.java Documents the additional metadata flag.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

jeqo added 2 commits October 15, 2026 00:47
…er [KC-582]

Phase 2 of the classic-t
8000
o-diskless switch starts when the controller
sets classicPrefixInRemoteStorage on a partition. The same commit sets
the ISR to the active assigned replicas, so it can also complete a
reassignment and elect a new leader. The flag therefore goes through
PartitionChangeBuilder and not through a raw record like the seal. A
raw record next to the builder's record could re-elect a replica that
the builder removed, and could increase the leader epoch twice.

setClassicPrefixInRemoteStorage() adds tag 103 to the record. If no
election or reassignment changed the leader, it sets the leader field
to the current leader, so the leader epoch increases. It also writes
the diskless leader epoch (tag 102) as the leader epoch that the record
produces. That epoch is new, so it is greater than every epoch used
before the flag. The block runs after the election and the reassignment
step, so it never replaces a new leader.

Nothing calls the setter yet. The controller RPC that commits the flag
follows.

Assisted-by: Claude Opus 5.5
… [KC-582]

The leader of a switching partition sends this RPC once it proves that
the classic prefix below the committed seal is in remote storage. The
controller then sets classicPrefixInRemoteStorage, which starts phase 2
of the classic-to-diskless switch. Nothing sends the RPC yet.

The controller checks each partition in this order:

1. The broker epoch, then an unknown topic or partition.
2. The flag already set. The same seal returns NONE with no record, and
   a different seal returns INVALID_REQUEST. This check runs before the
   epoch checks: the commit increases the leader epoch, so a retry after
   a lost response carries the old epoch.
3. The leader epoch: higher returns NOT_CONTROLLER, lower returns
   FENCED_LEADER_EPOCH.
4. The sender must be the current leader.
5. The seal must be committed and equal to the request seal.

The record comes from PartitionChangeBuilder with the target ISR set to
the active assigned replicas, so it can complete a reassignment and
elect a new leader in the same record. A partition without a leader
cannot reach the builder: fencing and controlled shutdown move
leadership and increase the leader epoch, so the old leader fails the
epoch check.

The DescribeTopicPartitions response carries the flag as tag 103 when
it is set, and the topic switch tool prints it with the phase.

Assisted-by: Claude Opus 5.5
@jeqo
jeqo force-pushed the jeqo/kc-582-controller-handling branch from d690560 to a63f25a Compare October 14, 2026 21:48
@jeqo
jeqo requested a balanced review from Copilot October 14, 2026 21:48
Copilot AI left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The storage transition combines ISR expansion, reassignment completion, and leadership epoch changes, warranting final human safety review.

0 open findings

2 resolved since last review

🧠 Review effort: Balanced

@jeqo
jeqo marked this pull request as ready for review October 15, 2026 08:55
@jeqo
jeqo requested a review from viktorsomogyi October 15, 2026 08:55
// The diskless leader epoch is the leader epoch that this record produces.
// The leader field is set at this point, and merge() increments the leader epoch once per record.
// Raw tags must be in increasing tag order for serialization: tag 102 before tag 103.
record.unknownTaggedFields().add(InitDisklessLogFields.encodeDisklessLeaderEpoch(partition.leaderEpoch + 1));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If we increase the diskless leader epoch, then we might expose ourselves to an unwanted truncation:

Given a seal offset 100 and a diskless epoch 7, consider the following:

  1. The seal commits at offset 100 while the partition leader epoch is 7. Tag 102 becomes 7.
  2. Consolidation materializes offsets 100–159 and stamps every batch with epoch 7.
  3. The new flag commit sets tag 103 and forces a leader epoch bump. The PR also overwrites tag 102 with partition.leaderEpoch + 1, so metadata now says Ed = 8.
  4. A follower holding valid diskless data asks for the end offset of epoch 7.
  5. The leader evaluates 7 < 8. It therefore classifies epoch 7 as classic and returns the seal, offset 100.
  6. The follower truncates its valid diskless region, offsets 100–159, back to offset 100.

We could perhaps put a check around it to ensure this field is only set if there is no diskless leader epoch yet.

@jeqo jeqo Oct 16, 2026 •
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is related to the proposal to move the diskless-leader-epoch capture from the seal-commit to the flag-commit. I've added a section on the PR description to make this more explicit. KC-588 implementation should cover this.

The case presented here would be possible with today's implementation where diskless-leader-epoch` is set along seal-commit; but this should not be a problem. The seal-commit should be updated later to not set the diskless-leader-epoch and something calls this new API.


partitionResponses.add(new SetClassicPrefixInRemoteStorageResponseData.PartitionResponse()
.setPartitionId(partitionId)
.setErrorCode(NONE.code()));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sending NONE here unconditionally may introduce a problem during leader election. Consider the following scenario:

  1. Replica 0 is leader at epoch 5. The assignment is [1, 2, 3, 0], with 0 removing and 3 adding. The seal is 100 and the phase-2 flag is false.
  2. Replica 0 sends SetClassicPrefixInRemoteStorage with seal 100 and leader epoch 5.
  3. The controller checks the epoch, the sender, and the seal. All three match, so it builds a partition change.
  4. The target ISR is the active assigned replicas [1, 2, 3, 0]. Replica 3 is present, so the in-flight reassignment completes and the new replica list is [1, 2, 3].
  5. Replica 0 is no longer eligible. The builder elects replica 1 and the leader epoch becomes 6. The same record sets the phase-2 flag.
  6. The response row for this partition uses error code NONE. A later retry with epoch 5 also returns NONE, because the flag is already set and the seal still matches.

We may need to detect if we're during a reassignment and return NEW_LEADER_ELECTED.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch. Let's take this as a follow-up to be implemented on KC-588. The sender should stop retries on success, and phase 2 starts from the metadata record that sets the flag, not from the response.

@jeqo
jeqo requested a review from viktorsomogyi October 16, 2026 09:30
@viktorsomogyi
viktorsomogyi merged commit ae2d2d4 into main Oct 16, 2026
17 checks passed
@viktorsomogyi
viktorsomogyi deleted the jeqo/kc-582-controller-handling branch October 16, 2026 09:31
jeqo added a commit that referenced this pull request Oct 19, 2026
… [KC-582] (#827)

* chore(inkless): mark the controller switch files as interleaved

ReplicationControlManager carries the diskless placement, election, and
classic-to-diskless switch commits. PartitionChangeBuilder gets the switch
flag commit. Both are upstream files with Inkless edits, so they need a
manual check on every upstream sync.

Assisted-by: Claude Opus 5.5

* feat(inkless:controller): add the switch flag to PartitionChangeBuilder [KC-582]

Phase 2 of the classic-to-diskless switch starts when the controller
sets classicPrefixInRemoteStorage on a partition. The same commit sets
the ISR to the active assigned replicas, so it can also complete a
reassignment and elect a new leader. The flag therefore goes through
PartitionChangeBuilder and not through a raw record like the seal. A
raw record next to the builder's record could re-elect a replica that
the builder removed, and could increase the leader epoch twice.

setClassicPrefixInRemoteStorage() adds tag 103 to the record. If no
election or reassignment changed the leader, it sets the leader field
to the current leader, so the leader epoch increases. It also writes
the diskless leader epoch (tag 102) as the leader epoch that the record
produces. That epoch is new, so it is greater than every epoch used
before the flag. The block runs after the election and the reassignment
step, so it never replaces a new leader.

Nothing calls the setter yet. The controller RPC that commits the flag
follows.

Assisted-by: Claude Opus 5.5

* feat(inkless:controller): add the SetClassicPrefixInRemoteStorage RPC [KC-582]

The leader of a switching partition sends this RPC once it proves that
the classic prefix below the committed seal is in remote storage. The
controller then sets classicPrefixInRemoteStorage, which starts phase 2
of the classic-to-diskless switch. Nothing sends the RPC yet.

The controller checks each partition in this order:

1. The broker epoch, then an unknown topic or partition.
2. The flag already set. The same seal returns NONE with no record, and
   a different seal returns INVALID_REQUEST. This check runs before the
   epoch checks: the commit increases the leader epoch, so a retry after
   a lost response carries the old epoch.
3. The leader epoch: higher returns NOT_CONTROLLER, lower returns
   FENCED_LEADER_EPOCH.
4. The sender must be the current leader.
5. The seal must be committed and equal to the request seal.

The record comes from PartitionChangeBuilder with the target ISR set to
the active assigned replicas, so it can complete a reassignment and
elect a new leader in the same record. A partition without a leader
cannot reach the builder: fencing and controlled shutdown move
leadership and increase the leader epoch, so the old leader fails the
epoch check.

The DescribeTopicPartitions response carries the flag as tag 103 when
it is set, and the topic switch tool prints it with the phase.

Assisted-by: Claude Opus 5.5
jeqo added a commit that referenced this pull request Oct 19, 2026
… [KC-582] (#827)

* chore(inkless): mark the controller switch files as interleaved

ReplicationControlManager carries the diskless placement, election, and
classic-to-diskless switch commits. PartitionChangeBuilder gets the switch
flag commit. Both are upstream files with Inkless edits, so they need a
manual check on every upstream sync.

Assisted-by: Claude Opus 5.5

* feat(inkless:controller): add the switch flag to PartitionChangeBuilder [KC-582]

Phase 2 of the classic-to-diskless switch starts when the controller
sets classicPrefixInRemoteStorage on a partition. The same commit sets
the ISR to the active assigned replicas, so it can also complete a
reassignment and elect a new leader. The flag therefore goes through
PartitionChangeBuilder and not through a raw record like the seal. A
raw record next to the builder's record could re-elect a replica that
the builder removed, and could increase the leader epoch twice.

setClassicPrefixInRemoteStorage() adds tag 103 to the record. If no
election or reassignment changed the leader, it sets the leader field
to the current leader, so the leader epoch increases. It also writes
the diskless leader epoch (tag 102) as the leader epoch that the record
produces. That epoch is new, so it is greater than every epoch used
before the flag. The block runs after the election and the reassignment
step, so it never replaces a new leader.

Nothing calls the setter yet. The controller RPC that commits the flag
follows.

Assisted-by: Claude Opus 5.5

* feat(inkless:controller): add the SetClassicPrefixInRemoteStorage RPC [KC-582]

The leader of a switching partition sends this RPC once it proves that
the classic prefix below the committed seal is in remote storage. The
controller then sets classicPrefixInRemoteStorage, which starts phase 2
of the classic-to-diskless switch. Nothing sends the RPC yet.

The controller checks each partition in this order:

1. The broker epoch, then an unknown topic or partition.
2. The flag already set. The same seal returns NONE with no record, and
   a different seal returns INVALID_REQUEST. This check runs before the
   epoch checks: the commit increases the leader epoch, so a retry after
   a lost response carries the old epoch.
3. The leader epoch: higher returns NOT_CONTROLLER, lower returns
   FENCED_LEADER_EPOCH.
4. The sender must be the current leader.
5. The seal must be committed and equal to the request seal.

The record comes from PartitionChangeBuilder with the target ISR set to
the active assigned replicas, so it can complete a reassignment and
elect a new leader in the same record. A partition without a leader
cannot reach the builder: fencing and controlled shutdown move
leadership and increase the leader epoch, so the old leader fails the
epoch check.

The DescribeTopicPartitions response carries the flag as tag 103 when
it is set, and the topic switch tool prints it with the phase.

Assisted-by: Claude Opus 5.5
jeqo added a commit that referenced this pull request Oct 19, 2026
… [KC-582] (#827)

* chore(inkless): mark the controller switch files as interleaved

ReplicationControlManager carries the diskless placement, election, and
classic-to-diskless switch commits. PartitionChangeBuilder gets the switch
flag commit. Both are upstream files with Inkless edits, so they need a
manual check on every upstream sync.

Assisted-by: Claude Opus 5.5

* feat(inkless:controller): add the switch flag to PartitionChangeBuilder [KC-582]

Phase 2 of the classic-to-diskless switch starts when the controller
sets classicPrefixInRemoteStorage on a partition. The same commit sets
the ISR to the active assigned replicas, so it can also complete a
reassignment and elect a new leader. The flag therefore goes through
PartitionChangeBuilder and not through a raw record like the seal. A
raw record next to the builder's record could re-elect a replica that
the builder removed, and could increase the leader epoch twice.

setClassicPrefixInRemoteStorage() adds tag 103 to the record. If no
election or reassignment changed the leader, it sets the leader field
to the current leader, so the leader epoch increases. It also writes
the diskless leader epoch (tag 102) as the leader epoch that the record
produces. That epoch is new, so it is greater than every epoch used
before the flag. The block runs after the election and the reassignment
step, so it never replaces a new leader.

Nothing calls the setter yet. The controller RPC that commits the flag
follows.

Assisted-by: Claude Opus 5.5

* feat(inkless:controller): add the SetClassicPrefixInRemoteStorage RPC [KC-582]

The leader of a switching partition sends this RPC once it proves that
the classic prefix below the committed seal is in remote storage. The
controller then sets classicPrefixInRemoteStorage, which starts phase 2
of the classic-to-diskless switch. Nothing sends the RPC yet.

The controller checks each partition in this order:

1. The broker epoch, then an unknown topic or partition.
2. The flag already set. The same seal returns NONE with no record, and
   a different seal returns INVALID_REQUEST. This check runs before the
   epoch checks: the commit increases the leader epoch, so a retry after
   a lost response carries the old epoch.
3. The leader epoch: higher returns NOT_CONTROLLER, lower returns
   FENCED_LEADER_EPOCH.
4. The sender must be the current leader.
5. The seal must be committed and equal to the request seal.

The record comes from PartitionChangeBuilder with the target ISR set to
the active assigned replicas, so it can complete a reassignment and
elect a new leader in the same record. A partition without a leader
cannot reach the builder: fencing and controlled shutdown move
leadership and increase the leader epoch, so the old leader fails the
epoch check.

The DescribeTopicPartitions response carries the flag as tag 103 when
it is set, and the topic switch tool prints it with the phase.

Assisted-by: Claude Opus 5.5
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

0