Upgrades and compatibility
Two versions of Felix can meet in five places. This page says what happens on a mismatch in each.
The five compatibility surfaces
Section titled “The five compatibility surfaces”| Surface | Between | On mismatch |
|---|---|---|
| Client protocol | client ↔ broker | Negotiated; old and new interoperate |
| Internal protocol | broker ↔ broker | Version bump is a hard cutover; new kinds are additive |
| Control-plane REST | broker ↔ control plane | Additive JSON; old and new interoperate |
| Fleet features | broker ↔ every other broker | Off until an operator finalizes it, which needs every serving broker to have it; then an older broker is refused |
| Storage format | broker ↔ its own disk | Unknown version refuses to start; no rollback |
Client protocol: negotiated, so order does not matter
Section titled “Client protocol: negotiated, so order does not matter”felix-wire’s frame flags are capabilities, not a version. A client offers
Auth.client_flags, the broker answers AuthOk.server_flags, and each side
uses the intersection. A peer that predates negotiation sends or receives a
plain Ok, which is read as ORIGINAL_V1_FLAGS. Those are the three bits that
existed before negotiation, and the set is frozen: adding to it would make new
clients assume support that old brokers do not have.
An unknown flag bit is rejected, not masked off, because a flag selects the payload layout and ignoring one means confidently misparsing the body.
So clients and brokers upgrade independently, in either order. A new client against an old broker loses the features the old broker did not advertise, and nothing else.
VERSION (1) exists for a change negotiation cannot express, such as a
header change. Bumping it would break every client at once, and nothing has
needed to.
Internal protocol: a version bump is a cutover
Section titled “Internal protocol: a version bump is a cutover”INTERNAL_VERSION (1) is checked on every frame. A peer speaking a
different one is refused. There is no negotiation: no capability
exchange in Hello, and no way to speak an older dialect on request.
A bump therefore requires every broker to restart together. There is no rolling upgrade across it, and no partial cluster. That is deliberate. The design says the version exists for “the change that cannot cover: the header, or an existing body layout”. It is still a maintenance window, and it should be planned as one.
Adding a message kind is the additive path, and behaves differently in each direction:
- New broker → old broker. The old one does not know the kind. A current
broker steps over the frame and answers
UnsupportedKind, keeping the stream. A much older one drops the stream instead, and because those streams multiplex every in-flight request to that peer, it takes the requests with it. They are retried, so the cost is latency, not loss. - Old broker → new broker. Nothing happens. An old broker never sends a kind it does not have.
So during a rolling upgrade, upgrade brokers one at a time and let each settle before the next. A new broker will send new kinds to peers that may refuse them, and refusal is the designed outcome. What is being waited out is the window in which that costs a dropped stream rather than a typed answer.
Control-plane REST: additive, and the broker tolerates absence
Section titled “Control-plane REST: additive, and the broker tolerates absence”The broker seeds from the control plane and watches its change feed. The shapes
are serde types shared through felix-common::membership, and optional fields
default to the pre-existing behaviour, so a field a broker does not know is
ignored and one the control plane does not send takes its default.
Upgrade the control plane first. A new broker may report fields an old control plane drops (the reports still land, minus the new information), while an old broker against a new control plane simply does not use what it cannot see. Neither direction fails, so the order is a preference rather than a requirement.
Catalog feeds on Postgres during a control-plane roll. A control plane
hands out each tenant, namespace, stream and cache change’s seq in commit
order only when every instance writing to the database takes the feed’s lock.
While older and newer control-plane instances share one database, a write
through an older instance can still commit out of order, and a broker can skip
that change until it restarts. The guarantee holds once
every control-plane instance runs a release that has it, so finish the roll
before relying on it, and keep catalog writes to a minimum while it is under
way.
Storage identities during a roll. A broker now registers the identity of
its data directory, and its replica reports name the identity each follower
was counted under. Placement never promotes a follower whose two identities
differ, and keeps a dead leader’s last report believed past the usual window
for one whose identities match. The control plane also stamps each report
with the leader that made it and the incarnation of every node it names, and
counts a follower without an identity only from a fresh report taken since it
last registered. A Postgres control plane runs migrations 0030 and 0031 on
start. Until every instance runs this release, a registration or report
written through an older instance carries neither identity nor stamp: that
follower is judged by the report’s age alone, as on the previous release,
until a current instance takes the broker’s next registration or the leader’s
next report. A Raft control plane stores identities once every member is at
metadata level 12, and the stamps at level 13; until then a follower reads as
unverified and a report as unstamped. A broker on the previous release
registers without an identity, so its followers count only through the stamp.
So until both the control plane and the brokers are rolled, a follower that
lost its disk can still be promoted: before the control plane, on what was
reported before it restarted, and before the brokers, on what a leader that
cannot see its identity reports after. Even with the control plane rolled, a
follower whose registration lost its identity (through an older instance, or
from an older broker) can be counted on what its leader reports from the old
connection after the restart, until the leader redials it: at most QUIC’s
idle timeout after a crash (#1281).
Avoid wiping or replacing a broker’s disk until the roll is done. A leader that
dies while placement is slow can leave its shard unplaceable, as before.
Clean stops and crashes. A broker now records in
<FELIX_DURABLE_STORAGE_DIR>/run-state whether it stopped cleanly. Under
FELIX_DURABLE_FSYNC_MODE periodic (the default) or none, one that
crashed, or whose SIGTERM drain ran past FELIX_SHUTDOWN_DRAIN_TIMEOUT_MS,
starts under a new storage id, and its leaders copy each shard it follows
again. The first start on this release finds no record and keeps its id
unless startup would repair a torn tail, so a roll of brokers stopped with
SIGTERM changes no id. A leader that crashes that way gives up the shards it
led instead of reopening them with the shorter log, and the control plane
promotes a follower for each, or names it again when every follower holds
less than it kept (#1295). Give the drain time
to finish during a roll; a broker killed partway through costs one copy per
shard it follows, and a failover per shard it leads. A downgrade and
upgrade again keeps whatever the newer release last recorded, so a crash on
the older release in between is not detected.
Fleet features: on only when an operator finalizes them
Section titled “Fleet features: on only when an operator finalizes them”A change to how brokers treat each other, such as how a key maps to a shard, cannot be negotiated per connection: two brokers can parse every frame and still route the same key differently. Those changes ship as fleet features. Each broker reports the ones it implements when it registers, and the control plane tracks which ones every live or draining broker supports. Support alone turns nothing on. A feature is enabled only when an operator finalizes it, and brokers act on the enabled set alone.
So a rolling upgrade is safe to stop or reverse at any point before the finalize. Old and new brokers mix freely, and any broker can be rolled back on its own. Nothing changes behaviour until you finalize.
Finalizing is one-way. The control plane refuses to finalize while any serving broker lacks the feature. After it, a broker without the feature is refused at registration with 409 naming the feature, and exits, rather than being let in to switch the fleet’s routing back. That holds for a broker that was down during the finalize too.
A new cluster starts with them finalized. A cluster that never ran an
older release has nothing to roll back to, so its first broker registration
finalizes every feature its brokers report, in order, except
publisher_principal, which stays an opt-in because it grows every record by
its publisher’s id. The control plane treats a cluster as new when no stream
or cache was ever created and no broker has registered twice, and, under the
Raft backend, only once every member runs this release. It finalizes
through the same checks as the command below, so a feature some broker does
not report stays off, along with the features that need it. If a
control-plane member is still starting when the brokers register, the
control plane retries the finalize in the background until every member
answers, for up to ten minutes. If it gives up, it logs a warning, and the
features are finalized as on an upgraded cluster, with the runbook below. Everything below applies to a cluster upgraded from an older
release, and to publisher_principal.
A new cluster: the first broker’s configuration wins
Section titled “A new cluster: the first broker’s configuration wins”Two broker settings drop features from what a broker reports:
FELIX_QUORUM_READS=lease drops lease_free_reads, and
FELIX_INTERNAL_FENCE=false drops majority_ack, lease_free_reads and
fenced_caches. The first broker to register decides what a new cluster
finalizes, and finalizing is one-way, so give every broker the same values
before any of them starts.
A broker started later with a setting the first one did not have is refused at registration with 409, logs the reason and exits:
node broker-2 does not support fleet feature(s) lease_free_reads that thefleet has enabled; run a build that has them, since an enabled feature cannotbe disabled; a broker started with FELIX_QUORUM_READS=lease does not supportlease_free_reads: unset FELIX_QUORUM_READS (the default reads by a round) andrestart the brokerWith FELIX_INTERNAL_FENCE=false the message names majority_ack and says
to unset FELIX_INTERNAL_FENCE instead. To recover, remove the setting from
that broker and restart it; it registers normally. If you meant the whole
cluster to run with the setting, the features cannot be turned off again:
create the cluster afresh (a new control-plane store and empty broker data
directories) with the setting on every broker from the start.
Runbook: rolling out a fleet feature
Section titled “Runbook: rolling out a fleet feature”-
Roll every broker to the new build, one at a time as in Upgrade order. Nothing turns on yet.
-
Verify. Run the new build for as long as you would want the option to roll back. Check that the feature shows as supported and that no broker is missing it:
Terminal window felix-controlplane admin featuresfelix-controlplane admin features finalize jump_hash_routing --dry-runThe dry run names any live or draining broker that lacks the feature. A broker that is down is not counted, and will be refused if it comes back on the old build, so replace or retire it first.
-
Finalize. This is the point of no return for the feature:
Terminal window felix-controlplane admin features finalize jump_hash_routingEvery broker turns it on within a heartbeat. Watch
felix_broker_fleet_feature_enabled{feature="jump_hash_routing"}go to 1 on each broker, orGET /v1/fleet/features.
After step 3, rolling back means a build that still has the feature. There is no way to disable a finalized feature.
What finalizing jump_hash_routing changes: streams created from then on map
routing keys to shards with jump consistent hashing, which keeps most keys in
place should a stream’s shard count ever grow (it cannot change today). Streams that already exist keep the modulo mapping they were created
with, and nothing about them moves.
generation_start
Section titled “generation_start”Once finalized, a leader writes a generation-start record whenever it starts leading a stream shard (a promotion, either end of a move, a cancelled move handing the shard back), and its quorum mark counts only past that record. A cache leader writes one on the shard’s cache log and one on its counter log, and each mark counts only past its own log’s record. That closes a way a promotion could lose an acknowledged record (Raft’s Figure 8; see the replication design notes). Until it is finalized, brokers count as before and keep that exposure. In-memory streams never write the record: their publishes take no log offsets and are not replicated, so there is nothing inherited for it to cover, and their shards open on promotion as before.
- Finalize it only after every broker runs a version that supports it. The control plane refuses the finalize otherwise, and the dry run above names the brokers still missing it.
- It is one-way twice over. Beyond the gate refusing an older broker, the first record rolls each log onto a v4 segment, which an older build cannot open (see Storage format). Take a backup before finalizing.
- Shards already serving are not interrupted. A shard keeps the generation it had when you finalized, and its leader writes no record at that generation, even when it restarts: the records it holds at that generation are its own. Its first record comes with its next leadership change.
- Subscribers see a gap. The record takes an offset that no reader
delivers. A client that negotiated
FLAG_EVENT_BATCH_SKIPPED(0x0800, see capability negotiation) is told about it asskipped_beforeon the next batch. An older client that treats every offset jump as a drop reports a drop of one at each leadership change.
majority_ack
Section titled “majority_ack”Once finalized, together with generation_start, a Quorum stream
acknowledges a write as soon as a majority of its replicas has answered that
it holds it at the leader’s generation. Neither the control plane’s replica
report nor the leader’s lease is on the write’s path any more, so a leader
that loses the control plane keeps acknowledging what its followers hold, and
a promoted leader always fences a majority before it serves. Leader
streams and reads keep the lease, and so do caches until fenced_caches is
finalized.
- Finalize
generation_startfirst.majority_ackhas no effect without it, so the control plane refuses to finalize it untilgeneration_startis enabled. - Upgrade every control-plane instance first. Don’t finalize
majority_ackwhile any control plane runs a build that rebuilds a failed-overQuorumshard’s replica set (see the changelog entry “AQuorumfailover keeps its replica set”). Such a control plane swaps the dead leader for a spare broker, and the promoted leader’s fence can then open without a write the old set acknowledged: when the cluster has more brokers than the replication factor, or when a majority of the set is down. - Every broker must fence on promotion. A broker running with
FELIX_INTERNAL_FENCE=falsedoes not reportmajority_ack, so the dry run names it and the finalize is refused until it runs with the fence. After the finalize, such a broker is refused at registration. - Nothing changes on the wire or on disk. Rolling a broker back to a build that has the feature is safe. Rolling it back to one without it is refused, as for any finalized feature.
- What clients see. A publish to a leader that has been replaced but has not heard yet waits and times out as “unknown”. It is not refused at once for a lapsed lease. The client retries it against the new leader, and an idempotent producer keeps it from landing twice.
lease_free_reads
Section titled “lease_free_reads”Once finalized, together with majority_ack and generation_start, a get or
counter get on a replicated Quorum cache no longer trusts the lease. After
the read takes its value, the broker sends the promotion fence at its own
generation to the shard’s replicas and answers once a majority, itself
included, has taken it.
It also takes readers and consumer groups on replicated Quorum shards off the
lease. Subscriptions, replay, Kafka fetches and cache watches keep going
through a lapsed lease, because they only see the committed mark. They end when
a replica refuses the broker for a newer leader, or when no majority has
confirmed it for a lease duration, and the client finds the shard again as it
does after a move. A group poll, ack, nack or dead-letter change is confirmed
by the same round before it is acknowledged.
- Finalize
generation_startandmajority_ackfirst.lease_free_readshas no effect until all three are finalized, so the control plane refuses it until both are enabled. Placement fails aQuorumstream over on its followers’ word only once all three are, and only against a leader whose latest replica report says it serves by round. A broker learns of the finalize on its next heartbeat and reports it after, so for a heartbeat or so after the finalize leaders still fail over on the lease. A Raft control plane keeps the report’s flag only once every member is at metadata level 11. - Change a stream’s consistency with every control-plane member
upgraded. A pass that read a stream as
Quorummust not replace its leader on its followers’ word after the stream becameLeader, so the change fences placement. On Postgres migration 0036 does that for every instance. Under Raft it takes every member at metadata level 19; until then a change of consistency fences nothing, as before. - Finalize with every control-plane member upgraded. The order is checked
by the member that serves the
finalizecall. During a rolling control-plane upgrade an older member does not check it, so finish the control-plane upgrade first, or finalize in order yourself. - Every broker must fence on promotion and read by round. As for
majority_ack, a broker running withFELIX_INTERNAL_FENCE=falsedoes not report the feature, and neither does one running withFELIX_QUORUM_READS=lease. The dry run names it and the finalize is refused until it runs without either. - Nothing changes on the wire or on disk. The round is the existing
Fence, sent at a generation the replica already accepted, which writes nothing. - What clients see. A broker cut off from the control plane keeps its
subscribers, watches and group consumers. A group operation on a broker
whose replicas no longer answer it fails with
leadership_lostinstead ofshard_unavailable, and each group write waits for one round. - What clients see. A read costs a round trip to the nearest majority
(concurrent reads of a shard share rounds). A leader that loses the control
plane keeps serving reads its replicas confirm. One cut off from its
replicas refuses them as
leadership_lost, which is retryable, as soon as the round fails instead of at its lease’s expiry. - Keeping the lease.
FELIX_QUORUM_READS=leasekeeps a broker’s reads on the lease, the faster path that is only as safe as the clocks and the lease margins. It keeps the whole fleet there: the broker does not reportlease_free_reads, so the feature cannot be finalized while it serves, and once the feature is finalized a broker started with it is refused at registration. The refusal namesFELIX_QUORUM_READS: unset it (reads then confirm by a round, the default) and restart the broker. Before upgrading a fleet that already finalizedlease_free_reads, removeFELIX_QUORUM_READS=leasefrom every broker, or each upgraded broker is refused.
publisher_principal
Section titled “publisher_principal”Once finalized, a durable stream stores the principal that published each record, and subscribers and consumer groups that ask are told it (see Who published an event).
- It is the one-way step. A record with its publisher is storage format v6, which an older broker refuses to open, and the feature is what lets a broker write one. Take a backup first.
- Replication carries it. A follower that predates the feature refuses a batch with publishers, which is why it waits for the whole fleet.
- Cost. Each record grows by its publisher’s length plus one byte: about 65 bytes for a 64-character principal id. A record from a connection with no principal grows by nothing.
- A single broker has no fleet;
FELIX_RECORD_PUBLISHERS=trueturns it on there, with the same one-way step. - What clients see. Nothing unless they ask. A client that asked gets the publisher on records written after the finalize, and none on older ones.
group_cursor_dependencies
Section titled “group_cursor_dependencies”Once finalized, each consumer-group cursor records the dead letters it passed records on, and a broker puts a cursor whose dead letters it no longer has back where one stands before it serves the shard. That closes a crash that keeps the cursor log’s tail and loses the dead-letter log’s, which otherwise skips a record.
- It is the one-way step for consumer groups. A cursor written this way is 33 bytes, and an older broker refuses to read it: its groups on that shard fail until it is upgraded again. Take a backup first.
- A single broker writes them from the start.
- What clients see. Nothing, unless a crash or failover left a cursor without its dead letter: the group then redelivers from where it last stood instead of skipping.
fenced_caches
Section titled “fenced_caches”majority_ack for caches. Once finalized, together with majority_ack and
generation_start, a replicated Quorum cache acknowledges a put, delete or
counter add as soon as a majority of its replicas has answered that it holds
it at the leader’s generation, without the report or the lease. A promoted
cache shard never opens on the lease: it fences a majority on its cache log
and its counter log, and takes the furthest ahead of each, before it serves.
Before the finalize, a broker of this build already fences a promoted cache
shard whenever every replica offers the CACHE_FENCE capability, and opens
it on the lease otherwise, but cache writes are still acknowledged on the
report and the lease. A broker of an older build can be promoted to a cache
shard without fencing it, which is why the lease stays until every broker
fences caches.
- Finalize
generation_startandmajority_ackfirst.fenced_cacheshas no effect until all three are finalized, so the control plane refuses it until both are enabled. - Upgrade every control-plane instance first. An older control plane
rebuilds a failed-over
Quorumcache’s replica set instead of keeping it, so the promoted leader’s fence could open without a write the old set acknowledged, as formajority_ackand streams. - Every broker must fence on promotion. A broker running with
FELIX_INTERNAL_FENCE=falsedoes not reportfenced_caches, so the dry run names it and the finalize is refused until it runs with the fence. - Nothing changes on the wire or on disk. A cache shard’s logs already
get their generation-start records once
generation_startis finalized. - What clients see. A cache write to a leader that has been replaced but
has not heard yet times out as “unknown” rather than being refused for a
lapsed lease, as a
Quorumpublish does undermajority_ack. A promoted cache shard waits for a majority of its replicas rather than opening alone.
stream_incarnations
Section titled “stream_incarnations”Once finalized, every stream the control plane creates gets an incarnation, higher for every creation of its name, and brokers keep each incarnation’s logs, group positions and dead letters in directories of their own. A stream deleted and created again under the same name then starts empty on every broker, including one that was down for the delete: the control plane keeps a tombstone per deleted name and serves it in the catalog snapshot.
Before the finalize a deleted stream’s data stays on the brokers, and the control plane refuses with 409 to create a stream under a name deleted since this release, naming the finalize to run first. A stream created again under an older deleted name still reopens the old data. That data goes once the name is deleted again after the finalize.
- Upgrade every control-plane instance first. Under the Raft backend the
finalize is refused with 409 until every member is at metadata version 10,
when proposed through an upgraded member. A member below version 10, such
as one still on
v0.6.0-preview.7, does not know the feature and accepts it. That is safe from version 14 on: until every member is at 14, streams are created at incarnation 0 and no delete of one records a tombstone, on every member. A cluster that ran an unreleased build between versions 10 and 13 may need members rebuilt from a snapshot; seedocs/metadata-raft-design.md. With Postgres, the first upgraded instance runs migration 0028 for the whole database. An instance from before this release that is still running after the finalize cannot create streams (the database refuses a stream without an incarnation), its deletes still record tombstones, and the database stamps its shard assignments with the incarnation. Its catalog answers leave incarnations out, which brokers refuse rather than misread, so upgrade every instance before finalizing. - Under Raft, deletes keep shard state until every member is at metadata version 10. Before that, deleting a namespace or tenant keeps its streams’ and caches’ shard assignments and replica reports, and deleting a stream or cache keeps its replica reports, as the older release does, so every member holds the same state. From then on a delete takes both.
- Every broker must report it. A broker that predates it would keep a new stream’s records in the directory a deleted stream of that name used.
- Nothing moves on disk. Existing streams keep incarnation 0 and their directory names. Downgrading a broker below the release that added it loses sight of incarnated streams’ data, which sits in directories it does not look for.
- Brokers check it. Once a broker has seen a stream with an incarnation it
records that on disk, and from then on, and from the start after a restart,
opens a stream’s logs only at the incarnation the catalog registered; a
follower refuses records for an incarnation it does not hold until its
catalog catches up, which it polls for at once. A broker never moves a name
to a lower incarnation or to one the control plane deleted, and refuses a
catalog record without the incarnation for a name it has seen incarnated;
felix_broker_stream_incarnation_refused_totalcounts both. - The finalize is one-way. As with every fleet feature, it cannot be undone, and a broker that does not report it is refused from then on.
- What clients see. Nothing. Stream answers carry
incarnationonce it is not 0.
cache_incarnations
Section titled “cache_incarnations”The same as stream_incarnations, for caches. Once finalized, every cache the
control plane creates gets an incarnation, and brokers keep each
incarnation’s entries and counters in directories of their own, so a cache
deleted and created again starts empty on every broker, including one that
was down for the delete.
- Upgrade every control-plane instance first. Under the Raft backend an upgraded member refuses the finalize with 409 until every member is at metadata version 14. A member still on the older release does not know the feature and accepts it. That is safe, because the members still apply every entry alike. Until every member is at version 14, caches are created at incarnation 0 and no cache delete records a tombstone; a cache create through an upgraded member is refused with 409 meanwhile. Finalize through an upgraded member anyway. With Postgres, the first upgraded instance runs migration 0032. An older instance still running after the finalize cannot create caches, its deletes still record tombstones, and the database stamps its cache shard assignments with the incarnation.
- Every broker must report it. A broker that predates it would open a new cache in the directories a deleted cache of that name used.
- Nothing moves on disk. Existing caches keep incarnation 0, their directory names and their data. Before the finalize a deleted cache’s data stays and a cache created again under the name reopens it, as before.
- Brokers check it. Once a broker has seen a cache with an incarnation it
opens a cache’s logs only at the registered incarnation, refuses stale
registrations (
felix_broker_cache_incarnation_refused_total), and a follower refuses cache and counter records for an incarnation it does not hold. - The finalize is one-way.
- What clients see. Nothing. Cache answers carry
incarnationonce it is not 0.
A control plane older than fleet features sends none, so brokers keep them
all off. Under the Raft backend a broker’s features are kept, and a feature
can be finalized, only once every control-plane member is at metadata
version 2; a broker registered before that reports them again on its next
restart. jump_hash_routing needs metadata version 3, because a member
before it would store a jump-hash stream as modulo: until every member is
there the finalize, and any request for a jump_hash stream, is refused
with 409. Upgrade the control plane first.
Under the Raft backend a member is added to the group only if it runs a
build at or above the metadata version the group already uses: the one a
finalized fleet feature needed (10 for stream_incarnations, 3 for
jump_hash_routing, 2 for the others), or the newest one any committed
entry needed, such as 10 once a delete has taken shard state. The leader
asks the new member before the membership changes and refuses a lower
build, or one that does not report a version, with an error naming both
versions. Added anyway, it would refuse entries the other members apply,
and its metadata would part ways with theirs. While neither applies, an older
build may join, as it would part-way through a rolling upgrade.
A member already in the group that comes back on an older build, for
example after its volume was lost, is held rather than fed: the leader
sends it no entries and no snapshot, logs a warning once, and sets
felix_meta_raft_member_below_floor{member} to 1. The leader holds a
member only on evidence: the member reported an older version, or it lost
the log it had acknowledged and has not reported a version since. A member
that answers with a conflict before it has accepted anything from the
current leader, as an empty one does, is held only until a probe of that
member alone answers, or gives up after 200 ms and a second try of up to a
second: a version at the group’s or no answer releases it. After a leader change, a member that is behind can be
held that briefly for each conflict while the leader finds where their logs
match. A member that is slow to answer or cannot be asked is otherwise not
held. A held member that is behind gets no heartbeats either, so it may
call elections; the others refuse them while they hear from the leader, but
once it is released it can cost one extra election. The group keeps committing with the other members, but a held
voter is one fewer failure the group can survive. Upgrade that member; replication to it resumes within about a
second of it starting on a build at the group’s version.
Storage format: the one that does not roll back
Section titled “Storage format: the one that does not roll back”The format version is in every segment header, and one newer than the build
knows is a Corruption, not a warning. A broker will not open a log written
by a newer build.
That makes a storage format bump irreversible without restoring from backup: a broker that has written one segment at the new version cannot be rolled back to the old build, because the old build refuses to read its own data directory.
Two things soften it, and neither is a rollback path:
- Indexes are derived. A
.indexfile whose version does not match is rebuilt from the segment it describes, so the index format can change freely. - The generation history is derived too. An
epochsfile that is absent, short, or fails its checksum reads as empty rather than failing, which costs automatic divergence repair and never a record.
Shard state files beside the segments are not versioned with them. The
ballot file a replica keeps from this release on, naming the leader it
accepted its generation from, is new: an older build ignores it and opens the
shard as before, so rolling back is safe. Rolling forward again, the first
leader to reach a replica at its accepted generation is recorded as the one it
accepted.
So before an upgrade that changes FORMAT_VERSION: take a backup, and treat
the rollout as one-way.
The current build reads format 6 but writes 3, which the previous release
reads, so upgrading to it is still reversible. It moves a log to a newer
version only to hold a record that needs it, and writes each such record only
once something enables it: a v4 segment for a generation-start record, once
generation_start is finalized (see generation_start
above); v5 for an atomic commit, once atomic_commit is; and v6 for a record
with its publisher, once publisher_principal is, or on a single broker with
FELIX_RECORD_PUBLISHERS=true (see
publisher_principal). Each of those is the one-way
step. Consumer-group cursors are not in the segment format but have a layout
of their own: a cluster moves to the newer one once
group_cursor_dependencies is finalized, and a
single broker from the start, after which an older build cannot read that
shard’s groups.
Upgrade order
Section titled “Upgrade order”For a release that changes none of the versions above (the ordinary case):
- Control plane, instance by instance. Readiness takes an instance out of rotation while it restarts, and brokers serve from the catalog they already hold while it is away. Under the Raft backend, restarting the leader pauses metadata writes for one election (about 1.2s).
- Brokers, one at a time, waiting for each to report ready and for its
shards to be back in their replica sets before the next.
GET /replication/haltedon the metrics port should be empty before continuing. A replica that halted during the previous restart is one that will not be there for the next. - Clients, whenever. The protocol negotiates.
If the release changes INTERNAL_VERSION, step 2 is not rolling: stop every
broker, upgrade, start every broker.
If it changes FORMAT_VERSION, back up first and do not plan to roll back.
If it adds a fleet feature, the feature stays off after step 2 until you finalize it. See the runbook above.
Rollback
Section titled “Rollback”| Changed | Rollback |
|---|---|
| Nothing versioned | Reverse the order above |
| Client protocol capability | Safe; clients lose the feature |
| Internal protocol kind | Safe once every broker is back on the old build; see the note on credentialed forwards below |
| Fleet feature, not finalized | Safe; broker by broker |
| Fleet feature, finalized | Not possible to a build without it; a broker on such a build is refused. Roll back to a build that still has the feature |
| Control-plane metadata version (Raft) | Safe until something at the new level is written, such as a jump-hash stream. After that, not below it: a member from metadata version 3 on refuses entries and snapshots with fields it does not know, and stops; an older one silently drops them. Roll the member forward |
INTERNAL_VERSION |
Cutover again, in both directions |
FORMAT_VERSION |
Not possible. Restore from backup |
What this does not cover
Section titled “What this does not cover”Topology, install steps and replacing a persistent volume are on Kubernetes Deployment. Adding, draining and removing a broker have their own page: Adding, draining and removing brokers.
The two observable checks available during any upgrade:
# Which replicas replication has stopped for. Empty before you continue.curl -s http://broker:8080/replication/halted | jq
# What a broker would actually run with, without starting it.felix-broker --print-configSee Observability for what to watch while a rollout is in progress.
A note on credentialed forwards
Section titled “A note on credentialed forwards”Forwarded publishes and cache operations carry the client’s credential on kinds of their own, and an upgraded owner refuses the credential-less legacy kinds. That refusal is the fix, not a side effect. During a rolling broker upgrade the two builds meet in both directions, and they behave differently:
- Upgraded broker forwarding to an old owner. The old owner answers
UnsupportedKind. The forwarder sends the legacy kind once instead, and the publish goes through. That owner checks nothing either way, so nothing is lost that the upgrade had gained. - Old broker forwarding to an upgraded owner. Refused
Unauthorized, and the client’s publish fails, until that broker is upgraded too.
So the window is bounded by how long un-upgraded brokers keep forwarding to
upgraded ones: upgrade brokers quickly and in one pass, and expect publishes
that cross the boundary in that direction to fail with Unauthorized while it
is open. Rolling back closes it the same way in reverse.
