Skip to main content

krabka_metadata/
records.rs

1//! Versioned metadata records.
2//!
3//! wincode encodes a [`MetadataRecord`] as its variant index, then the fields
4//! of the variant in declaration order. Nothing is length-prefixed, so a reader
5//! cannot skip a variant it does not know: it fails to decode it. The variant
6//! order and every field layout are part of the krabka 1.x on-disk contract. A
7//! new variant goes at the end. The golden bytes in `wincode_contract.rs` pin
8//! one value of every variant.
9
10pub use krabka_ids::LeaderEpoch;
11pub use krabka_voters::NodeId;
12use serde::{Deserialize, Serialize};
13use uuid::Uuid;
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct TopicRecord {
17    pub name: String,
18    pub topic_id: Uuid,
19    pub partitions: i32,
20    pub replication_factor: i16,
21}
22
23fn default_partition_epoch() -> i32 {
24    -1
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
28pub struct PartitionRecord {
29    pub topic: String,
30    pub partition: i32,
31    pub leader: NodeId,
32    pub replicas: Vec<NodeId>,
33    pub isr: Vec<NodeId>,
34    /// Per-partition leader epoch (KIP-320). Bumped on every leader change.
35    /// Older on-disk metadata is not migrated. `#[serde(transparent)]` on
36    /// [`LeaderEpoch`] keeps the on-disk bincode bytes identical to a bare
37    /// `i32`.
38    pub leader_epoch: LeaderEpoch,
39    /// Replicas being added in an in-flight reassignment. Empty when no
40    /// reassignment in flight. KIP-455.
41    pub adding_replicas: Vec<NodeId>,
42    /// Replicas being removed in an in-flight reassignment. Empty when
43    /// no reassignment in flight. KIP-455.
44    pub removing_replicas: Vec<NodeId>,
45    /// KIP-858: the log-directory UUID that hosts each replica, parallel to
46    /// [`Self::replicas`] in the same index order. The reserved ids are
47    /// Kafka's `DirectoryId` sentinels (see [`crate::directory_id`]):
48    /// [`MIGRATING`](crate::directory_id::MIGRATING), the nil UUID, is a
49    /// replica placed before `3.7-IV2` or on a broker with no log-dir ids,
50    /// and [`UNASSIGNED`](crate::directory_id::UNASSIGNED) is a replica the
51    /// controller placed on a multi-dir broker that has not yet reported its
52    /// `AssignReplicasToDirs`. An empty list reads as every replica
53    /// `MIGRATING`, as Kafka's `PartitionRegistration` reads it. The
54    /// controller matches this against the replica slot of a broker to map
55    /// the failed-dir UUID of that broker to the partitions it must fail
56    /// over.
57    pub directories: Vec<Uuid>,
58    /// KIP-631: per-partition state epoch. It increments on every state
59    /// change, such as a leader election, an ISR change, or a reassignment.
60    /// It is 0 on creation. The default of -1 matches the KIP-631 schema
61    /// default, for compatibility with records written before this field
62    /// existed.
63    #[serde(default = "default_partition_epoch")]
64    pub partition_epoch: i32,
65}
66
67/// KIP-858 directory-assignment delta. A broker reports which log-dir UUID
68/// hosts its replica of `(topic, partition)`. Apply treats it as a DELTA: it
69/// sets ONLY the slot of the reporting replica in
70/// `PartitionRecord.directories` and never touches leader, isr, replicas,
71/// adding, or removing. It therefore cannot clobber a concurrent reassignment
72/// or ISR change. On the `KRaft` log it is encoded as Kafka's standard
73/// `PartitionChangeRecord` with only the directories field set.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct PartitionDirAssignmentRecord {
76    pub topic: String,
77    pub partition: i32,
78    /// The reporting broker (must be a replica of the partition).
79    pub replica: NodeId,
80    /// The log-directory UUID hosting this broker's replica.
81    pub directory: Uuid,
82}
83
84/// Diskless offset-sequencer delta: advance a partition's committed
85/// next-offset by `count`.
86///
87/// Applied as a delta, never a full-record replace, so sequential advances on
88/// the committed metadata log yield a gap-free, strictly-monotonic, unique
89/// offset sequence. On the `KRaft` log it still uses a Krabka-private carrier.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct PartitionOffsetAdvanceRecord {
92    pub topic: String,
93    pub partition: i32,
94    /// Offsets consumed by the produce group. The producer's base offset is the
95    /// committed next-offset before this increment applies.
96    pub count: i64,
97}
98
99/// KIP-966 eligible-leader state for one partition. Empty vectors clear it.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct PartitionElrRecord {
102    pub topic: String,
103    pub partition: i32,
104    pub eligible_leader_replicas: Vec<NodeId>,
105    pub last_known_elr: Vec<NodeId>,
106}
107
108/// KIP-704 state of an uncleanly elected leader.
109#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
110#[repr(i8)]
111pub enum LeaderRecoveryState {
112    #[default]
113    Recovered = 0,
114    Recovering = 1,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct PartitionRecoveryRecord {
119    pub topic: String,
120    pub partition: i32,
121    pub state: LeaderRecoveryState,
122}
123
124/// A Kafka `PartitionChangeRecord` can carry assignment, ELR, and recovery
125/// changes together. This record preserves that atomic update on replay.
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct PartitionUpdateRecord {
128    pub partition: PartitionRecord,
129    pub eligible_leader_replicas: Option<Vec<NodeId>>,
130    pub last_known_elr: Option<Vec<NodeId>>,
131    pub recovery_state: Option<LeaderRecoveryState>,
132}
133
134/// A single named listener endpoint advertised by a broker. It is stored as a
135/// list on [`BrokerRegistrationRecord::endpoints`], so KRaft-style metadata
136/// can advertise per-listener `host:port` and protocol triples to clients on
137/// `Metadata` v9+. A legacy single-listener broker leaves the list empty and
138/// relies on the top-level `host` and `port` fields.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct BrokerEndpoint {
141    /// Listener name (e.g. `"PLAINTEXT"`, `"SSL"`, `"SASL_SSL"`).
142    pub name: String,
143    pub host: String,
144    pub port: u16,
145    pub protocol: krabka_security::ListenerProtocol,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149pub struct BrokerRegistrationRecord {
150    pub node_id: NodeId,
151    /// KIP-903 broker epoch: the raft log offset at which this registration
152    /// record committed. The controller leader assigns it at append time in
153    /// `on_submit_change`. A freshly-built literal carries `0` until the
154    /// leader stamps it. `AlterPartition` uses it to fence stale replicas
155    /// from the ISR.
156    pub broker_epoch: i64,
157    /// KIP-631: UUID that identifies this specific process invocation of the
158    /// broker. Generated once at first boot and persisted in
159    /// `{log_dir}/incarnation_id`. A JVM controller uses it to detect
160    /// broker restarts and fence stale replica memberships.
161    #[serde(default)]
162    pub incarnation_id: uuid::Uuid,
163    /// Legacy single-listener host, used as inter-broker default and by
164    /// pre-v9 `Metadata` responses. v9+ projects [`Self::endpoints`].
165    pub host: String,
166    pub port: u16,
167    pub rack: Option<String>,
168    /// Per-listener endpoints. Empty on records written before this
169    /// field was added; populated from
170    /// `BrokerConfig::effective_listeners()` for self-registration.
171    pub endpoints: Vec<BrokerEndpoint>,
172    /// KIP-858 stable IDs for the broker's online log directories.
173    /// Empty at metadata versions before `3.7-IV2` and in legacy snapshots.
174    #[serde(default)]
175    pub log_dirs: Vec<uuid::Uuid>,
176    /// KIP-631 fencing state. Kafka writes a new registration fenced (the
177    /// `RegisterBrokerRecord` schema default) and unfences it with a
178    /// [`BrokerRegistrationChangeRecord`] once the broker catches up.
179    pub fenced: bool,
180    /// KIP-841: the broker is in controlled shutdown. Only a
181    /// [`BrokerRegistrationChangeRecord`] sets it; only a new registration
182    /// clears it.
183    pub in_controlled_shutdown: bool,
184    /// KIP-1066 cordoned log directories. `None` until the broker first
185    /// reports them in a heartbeat, as in Kafka's `BrokerRegistration`. Only
186    /// carried from `metadata.version` `4.3-IV0`.
187    pub cordoned_log_dirs: Option<Vec<uuid::Uuid>>,
188    /// KIP-584 feature ranges advertised by this broker at registration.
189    /// Empty only for legacy Krabka snapshots written before the ranges were
190    /// retained in the image.
191    #[serde(default)]
192    pub features: std::collections::BTreeMap<String, (i16, i16)>,
193}
194
195/// KIP-919 controller registration persisted in the metadata log.
196///
197/// Static controller voters still register their current process incarnation,
198/// listener endpoints, and supported feature ranges.  Keeping that state in
199/// the image lets every controller replay the same registration and gives JVM
200/// peers the `RegisterControllerRecord` they expect in a mixed quorum.
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202pub struct ControllerRegistrationRecord {
203    pub node_id: NodeId,
204    pub incarnation_id: Uuid,
205    pub zk_migration_ready: bool,
206    pub endpoints: Vec<BrokerEndpoint>,
207    pub features: std::collections::BTreeMap<String, (i16, i16)>,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211pub struct DeleteTopicRecord {
212    pub name: String,
213}
214
215/// KIP-919 / `UnregisterBroker` (`api_key` 64), Kafka's
216/// `UnregisterBrokerRecord` (metadata apiKey 1). Marks a broker as
217/// permanently unregistered: the admin operator confirms that the broker is
218/// gone for good and asks the cluster to drop its registration entry
219/// from the metadata image. Later `Metadata` responses no longer
220/// advertise the endpoints of the broker, and clients stop routing to it.
221///
222/// As in `ClusterControlManager.replay(UnregisterBrokerRecord)`, the record
223/// removes the registration only when `broker_epoch` is the epoch of the
224/// current registration. An apply against an unknown `node_id` or another
225/// epoch does nothing, so a second apply is a no-op.
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
227pub struct UnregisterBrokerRecord {
228    pub node_id: NodeId,
229    /// The epoch of the registration this record removes.
230    pub broker_epoch: i64,
231}
232
233/// A change to the fencing state of a broker registration, as Kafka's
234/// `BrokerRegistrationFencingChange` encodes it in
235/// `BrokerRegistrationChangeRecord.Fenced`.
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
237pub enum FencingChange {
238    /// `-1`: the broker has been unfenced.
239    Unfence,
240    /// `0`: no change.
241    None,
242    /// `1`: the broker has been fenced.
243    Fence,
244}
245
246impl FencingChange {
247    /// The `Fenced` byte Kafka writes for this change.
248    #[must_use]
249    pub fn wire_value(self) -> i8 {
250        match self {
251            Self::Unfence => -1,
252            Self::None => 0,
253            Self::Fence => 1,
254        }
255    }
256
257    /// Reads a `Fenced` byte, or `None` for a value Kafka does not define.
258    #[must_use]
259    pub fn from_wire(value: i8) -> Option<Self> {
260        match value {
261            -1 => Some(Self::Unfence),
262            0 => Some(Self::None),
263            1 => Some(Self::Fence),
264            _ => None,
265        }
266    }
267
268    /// The fenced state after this change applies to `fenced`.
269    #[must_use]
270    pub fn apply(self, fenced: bool) -> bool {
271        match self {
272            Self::Unfence => false,
273            Self::None => fenced,
274            Self::Fence => true,
275        }
276    }
277}
278
279/// Kafka's `BrokerRegistrationChangeRecord` (metadata apiKey 17): a delta on
280/// the registration of one broker.
281///
282/// As in `ClusterControlManager.replay(BrokerRegistrationChangeRecord)`, the
283/// image applies it only when `broker_epoch` is the epoch of the current
284/// registration; against an unknown broker or another epoch it does nothing.
285/// `in_controlled_shutdown` true is Kafka's `IN_CONTROLLED_SHUTDOWN` (`1`) and
286/// false is `NONE` (`0`); Kafka has no change that leaves controlled shutdown,
287/// only a new registration does. An empty `log_dirs` and a `None`
288/// `cordoned_log_dirs` mean no change.
289#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290pub struct BrokerRegistrationChangeRecord {
291    pub node_id: NodeId,
292    pub broker_epoch: i64,
293    pub fenced: FencingChange,
294    pub in_controlled_shutdown: bool,
295    /// KIP-858: the broker's online log directories after the change.
296    pub log_dirs: Vec<Uuid>,
297    /// KIP-1066: the broker's cordoned log directories after the change.
298    pub cordoned_log_dirs: Option<Vec<Uuid>>,
299}
300
301impl BrokerRegistrationChangeRecord {
302    /// A change that updates nothing, for `node_id` at `broker_epoch`. Set the
303    /// fields to change on the result.
304    #[must_use]
305    pub fn no_change(node_id: NodeId, broker_epoch: i64) -> Self {
306        Self {
307            node_id,
308            broker_epoch,
309            fenced: FencingChange::None,
310            in_controlled_shutdown: false,
311            log_dirs: Vec::new(),
312            cordoned_log_dirs: None,
313        }
314    }
315
316    /// The registration after this change applies to `current`, as Kafka's
317    /// `BrokerRegistration.cloneWith` builds it. The caller checks the epoch.
318    #[must_use]
319    pub fn applied_to(&self, current: &BrokerRegistrationRecord) -> BrokerRegistrationRecord {
320        BrokerRegistrationRecord {
321            fenced: self.fenced.apply(current.fenced),
322            in_controlled_shutdown: current.in_controlled_shutdown || self.in_controlled_shutdown,
323            log_dirs: if self.log_dirs.is_empty() {
324                current.log_dirs.clone()
325            } else {
326                self.log_dirs.clone()
327            },
328            cordoned_log_dirs: self
329                .cordoned_log_dirs
330                .clone()
331                .or_else(|| current.cordoned_log_dirs.clone()),
332            ..current.clone()
333        }
334    }
335}
336
337/// KIP-1312 `UnregisterController` (`api_key` 94), Kafka trunk's
338/// `UnregisterControllerRecord` (metadata apiKey 29). Removes the
339/// registration of one controller. An apply against an unknown `node_id`
340/// does nothing.
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
342pub struct UnregisterControllerRecord {
343    pub node_id: NodeId,
344}
345
346/// Mutable topic configuration overrides. Authoritative target state:
347/// each `V1TopicConfig` record fully replaces the previous override map
348/// for `topic`. An empty map clears all overrides. The `AlterConfigs`
349/// handler merges before it submits the record.
350#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
351pub struct TopicConfigRecord {
352    pub topic: String,
353    pub overrides: std::collections::BTreeMap<String, String>,
354}
355
356/// Per-broker configuration key/value pair. `Some(value)` = set; `None` = delete.
357///
358/// Kafka names the cluster-wide broker-default resource with an empty string.
359/// [`DEFAULT_BROKER_CONFIG_NODE_ID`] represents that resource internally; the
360/// `KRaft` translator maps it back to the empty resource name.
361#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
362pub struct BrokerConfigRecord {
363    pub node_id: NodeId,
364    pub config_name: String,
365    /// `Some(value)` = set; `None` = delete.
366    pub config_value: Option<String>,
367}
368
369/// Internal identity for Kafka's cluster-wide default broker-config resource.
370/// Broker IDs are non-negative signed 32-bit integers on the Kafka wire, so
371/// this value cannot collide with a real broker.
372pub const DEFAULT_BROKER_CONFIG_NODE_ID: NodeId = NodeId(u64::MAX);
373
374/// KIP-714 client-metrics subscription config. Authoritative target
375/// state: each `V1ClientMetricsConfig` fully replaces the previous
376/// override map for `name`, the subscription name. An empty map deletes
377/// the subscription. The `IncrementalAlterConfigs` handler merges before it
378/// submits the record, in the same pattern as [`TopicConfigRecord`].
379#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
380pub struct ClientMetricsConfigRecord {
381    pub name: String,
382    pub configs: std::collections::BTreeMap<String, String>,
383}
384
385/// KIP-1071 dynamic configuration for one group resource. Each record is the
386/// authoritative override map for `group_id`; an empty map clears the resource.
387#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
388pub struct GroupConfigRecord {
389    pub group_id: String,
390    pub configs: std::collections::BTreeMap<String, String>,
391}
392
393#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
394pub struct QuotaEntity {
395    pub entity_type: String,
396    pub entity_name: Option<String>,
397}
398
399#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
400pub struct ClientQuotaRecord {
401    /// Canonicalized entity tuple, sorted alphabetically by `entity_type`.
402    pub entity: Vec<QuotaEntity>,
403    pub config_key: String,
404    pub config_value: Option<f64>,
405}
406
407/// Durable controller state for cluster-wide producer-ID block allocation.
408/// `next_producer_id` is the first ID not covered by any committed block.
409#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
410pub struct ProducerIdsRecord {
411    pub broker_id: NodeId,
412    pub broker_epoch: i64,
413    pub next_producer_id: i64,
414}
415
416#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
417pub struct ScramCredentialRecord {
418    pub user: String,
419    pub mechanism: krabka_security::SaslMechanism,
420    pub salt: Vec<u8>,
421    pub stored_key: Vec<u8>,
422    pub server_key: Vec<u8>,
423    pub iterations: u32,
424}
425
426#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
427pub struct DeleteScramCredentialRecord {
428    pub user: String,
429    pub mechanism: krabka_security::SaslMechanism,
430}
431
432/// A single delegation token's authoritative state (KIP-48).
433/// The record has replacement semantics: a new record with the same
434/// `token_id` overwrites the prior one in the image. Both Create and Renew
435/// use that. Removal goes through
436/// [`DeleteDelegationTokenRecord`].
437///
438/// The fields are those of Kafka's `DelegationTokenRecord`. Like Kafka's, the
439/// record carries no HMAC: the token's password is
440/// [`krabka_security::compute_token_hmac`] of `token_id` under the cluster's
441/// secret key, recomputed wherever it is needed.
442#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
443pub struct DelegationTokenRecord {
444    pub token_id: String,
445    pub owner: krabka_security::KafkaPrincipal,
446    /// The principal that created the token, which is the owner unless an
447    /// administrator created it on the owner's behalf (KIP-373).
448    pub requester: krabka_security::KafkaPrincipal,
449    pub issue_timestamp_ms: i64,
450    pub expiry_timestamp_ms: i64,
451    /// Issue plus max-lifetime. A renewal cannot push `expiry_timestamp_ms`
452    /// past this ceiling.
453    pub max_timestamp_ms: i64,
454    pub renewers: Vec<krabka_security::KafkaPrincipal>,
455}
456
457/// Tombstone record that removes a delegation token (KIP-48)
458/// from the image. The `ExpireDelegationToken` handlers and the
459/// background expiry sweep emit it.
460#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
461pub struct DeleteDelegationTokenRecord {
462    pub token_id: String,
463}
464
465/// KIP-853: finalizes the cluster-wide kraft.version feature level.
466#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
467pub struct KRaftVersionRecord {
468    pub kraft_version: u16,
469}
470
471/// KIP-853: full snapshot of the controller voter set.
472#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
473pub struct VotersRecord {
474    pub voters: crate::voters::VoterSet,
475}
476
477/// KIP-584 finalized feature level. `level` is the finalized
478/// `max_version_level` for `name`. `level == 0` is the KIP-584 sentinel
479/// for "delete this finalized feature": `MetadataImage::apply` removes the
480/// entry and does not store a zero. Replacement semantics: a later record
481/// with the same `name` overwrites the previous level.
482#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
483pub struct FeatureLevelRecord {
484    pub name: String,
485    pub level: i16,
486}
487
488/// Snapshot-only carrier for the KIP-584 finalized-features epoch.
489///
490/// Apply normally derives the epoch, with one bump per applied
491/// `V1FeatureLevel`, so it tracks the history of `UpdateFeatures` calls and
492/// not the live feature count. That derivation cannot survive a snapshot. A
493/// snapshot stores resulting *state*, so it emits at most one
494/// `V1FeatureLevel` per live feature, which is fewer records than the
495/// original apply history. A replay of those records alone reconstructs a
496/// smaller epoch and diverges from a replica that replayed the full log.
497///
498/// [`MetadataImage::to_records`](crate::MetadataImage::to_records) therefore
499/// emits this record last, and
500/// [`MetadataImage::apply`](crate::MetadataImage::apply) SETS the epoch from
501/// it verbatim and does not bump it. That pins the reconstructed epoch to the
502/// original. Only `to_records` produces this record, and only snapshot replay
503/// consumes it. Nothing submits it as a controller change, so it never appears
504/// in the live Raft log.
505#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
506pub struct FeaturesEpochRecord {
507    pub epoch: i64,
508}
509
510#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
511#[non_exhaustive]
512pub enum MetadataRecord {
513    V1Topic(TopicRecord),
514    V1Partition(PartitionRecord),
515    V1BrokerRegistration(BrokerRegistrationRecord),
516    V1DeleteTopic(DeleteTopicRecord),
517    V1TopicConfig(TopicConfigRecord),
518    V1ScramCredential(ScramCredentialRecord),
519    V1DeleteScramCredential(DeleteScramCredentialRecord),
520    V1AccessControlEntry(crate::AclEntry),
521    V1DeleteAccessControlEntry(crate::AclEntryFilter),
522    V1BrokerConfig(BrokerConfigRecord),
523    V1ClientQuota(ClientQuotaRecord),
524    V1ProducerIds(ProducerIdsRecord),
525    V1DelegationToken(DelegationTokenRecord),
526    V1DeleteDelegationToken(DeleteDelegationTokenRecord),
527    V1UnregisterBroker(UnregisterBrokerRecord),
528    V1KRaftVersion(KRaftVersionRecord),
529    V1Voters(VotersRecord),
530    V1FeatureLevel(FeatureLevelRecord),
531    V1ClientMetricsConfig(ClientMetricsConfigRecord),
532    /// Snapshot-only: pins the finalized-features epoch on reconstruction.
533    /// Nothing submits it through the controller. See [`FeaturesEpochRecord`].
534    V1FeaturesEpoch(FeaturesEpochRecord),
535    /// KIP-858 directory-assignment delta (see [`PartitionDirAssignmentRecord`]).
536    /// Applied as a merge into one replica's `directories` slot; on the `KRaft`
537    /// log it rides a Krabka-private carrier so it stays a delta end-to-end.
538    V1PartitionDirAssignment(PartitionDirAssignmentRecord),
539    /// Diskless offset-sequencer delta (see [`PartitionOffsetAdvanceRecord`]).
540    /// Applied as an increment to the partition's committed next-offset.
541    V1PartitionOffsetAdvance(PartitionOffsetAdvanceRecord),
542    /// KIP-1071 dynamic GROUP resource configuration.
543    V1GroupConfig(GroupConfigRecord),
544    /// KIP-919 controller registration.
545    V1ControllerRegistration(ControllerRegistrationRecord),
546    /// One write-freeze registry entry (see
547    /// [`TopicFreezeRecord`](crate::write_freeze::TopicFreezeRecord)).
548    /// `frozen: false` removes the entry and is the thaw, so the registry
549    /// needs no separate tombstone record.
550    V1TopicFreeze(crate::write_freeze::TopicFreezeRecord),
551    /// One break-glass proposal (see
552    /// [`BreakGlassProposalRecord`](crate::break_glass::BreakGlassProposalRecord)).
553    /// Replacement semantics on `proposal_id`: an approval and a consumption
554    /// each write the whole record back.
555    V1BreakGlassProposal(crate::break_glass::BreakGlassProposalRecord),
556    /// Tombstone that removes a break-glass proposal by id. The expiry sweep
557    /// emits it, in the same shape as `V1DeleteDelegationToken`.
558    V1DeleteBreakGlassProposal(Uuid),
559    /// KIP-966 per-partition eligible-leader state.
560    V1PartitionElr(PartitionElrRecord),
561    /// KIP-704 per-partition leader recovery state.
562    V1PartitionRecovery(PartitionRecoveryRecord),
563    /// Atomic standard-KRaft partition update decoded from a combined delta.
564    V1PartitionUpdate(PartitionUpdateRecord),
565    /// Kafka's `BrokerRegistrationChangeRecord`: a fencing, controlled-shutdown
566    /// or log-dir delta on one broker registration, applied only at the
567    /// registration's epoch.
568    V1BrokerRegistrationChange(BrokerRegistrationChangeRecord),
569    /// KIP-1312 controller unregistration.
570    V1UnregisterController(UnregisterControllerRecord),
571}
572
573#[cfg(test)]
574mod tests {
575
576    use serde_wincode::SerdeCompat;
577    use wincode::{Deserialize as _, Serialize as _};
578
579    use super::*;
580
581    fn round_trip(r: &MetadataRecord) -> MetadataRecord {
582        let bytes = <SerdeCompat<MetadataRecord>>::serialize(r).unwrap();
583        <SerdeCompat<MetadataRecord>>::deserialize(&bytes).unwrap()
584    }
585
586    #[test]
587    fn feature_level_round_trip() {
588        let r = MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
589            name: "metadata.version".into(),
590            level: 1,
591        });
592        assert2::assert!(round_trip(&r) == r);
593    }
594
595    #[test]
596    fn group_config_round_trip() {
597        let r = MetadataRecord::V1GroupConfig(GroupConfigRecord {
598            group_id: "streams-app".into(),
599            configs: std::collections::BTreeMap::from([(
600                "streams.num.standby.replicas".into(),
601                "1".into(),
602            )]),
603        });
604        assert2::assert!(round_trip(&r) == r);
605    }
606
607    #[test]
608    fn controller_registration_round_trip() {
609        let r = MetadataRecord::V1ControllerRegistration(ControllerRegistrationRecord {
610            node_id: NodeId(3),
611            incarnation_id: Uuid::from_u128(7),
612            zk_migration_ready: false,
613            endpoints: vec![BrokerEndpoint {
614                name: "CONTROLLER".into(),
615                host: "controller-3".into(),
616                port: 9093,
617                protocol: krabka_security::ListenerProtocol::Plaintext,
618            }],
619            features: std::collections::BTreeMap::from([("metadata.version".into(), (7, 25))]),
620        });
621        assert2::assert!(round_trip(&r) == r);
622    }
623
624    #[test]
625    fn producer_ids_round_trip() {
626        let r = MetadataRecord::V1ProducerIds(ProducerIdsRecord {
627            broker_id: NodeId(3),
628            broker_epoch: 9,
629            next_producer_id: 2_000,
630        });
631        assert2::assert!(round_trip(&r) == r);
632    }
633
634    #[test]
635    fn features_epoch_round_trip() {
636        let r = MetadataRecord::V1FeaturesEpoch(FeaturesEpochRecord { epoch: 7 });
637        assert2::assert!(round_trip(&r) == r);
638    }
639
640    #[test]
641    fn topic_record_round_trip() {
642        let r = MetadataRecord::V1Topic(TopicRecord {
643            name: "t".into(),
644            topic_id: Uuid::new_v4(),
645            partitions: 3,
646            replication_factor: 1,
647        });
648        assert2::assert!(round_trip(&r) == r);
649    }
650
651    #[test]
652    fn partition_record_round_trip() {
653        let r = MetadataRecord::V1Partition(PartitionRecord {
654            topic: "t".into(),
655            partition: 0,
656            leader: NodeId(1),
657            replicas: vec![NodeId(1), NodeId(2), NodeId(3)],
658            isr: vec![NodeId(1), NodeId(2)],
659            leader_epoch: LeaderEpoch(0),
660            adding_replicas: vec![],
661            removing_replicas: vec![],
662            directories: vec![Uuid::from_u128(1), Uuid::from_u128(2), Uuid::nil()],
663            partition_epoch: 0,
664        });
665        assert2::assert!(round_trip(&r) == r);
666    }
667
668    #[test]
669    fn partition_dir_assignment_round_trip() {
670        let r = MetadataRecord::V1PartitionDirAssignment(PartitionDirAssignmentRecord {
671            topic: "t".into(),
672            partition: 2,
673            replica: NodeId(3),
674            directory: Uuid::from_u128(0xAB),
675        });
676        assert2::assert!(round_trip(&r) == r);
677    }
678
679    #[test]
680    fn broker_registration_round_trip() {
681        let r = MetadataRecord::V1BrokerRegistration(BrokerRegistrationRecord {
682            node_id: NodeId(7),
683            broker_epoch: 0,
684            incarnation_id: Uuid::from_u128(0xdeadbeef_cafe_babe_0123_456789abcdef),
685            host: "192.168.1.10".into(),
686            port: 9092,
687            rack: Some("us-east-1a".into()),
688            log_dirs: vec![],
689            fenced: false,
690            in_controlled_shutdown: false,
691            cordoned_log_dirs: None,
692            endpoints: vec![],
693            features: std::collections::BTreeMap::new(),
694        });
695        assert2::assert!(round_trip(&r) == r);
696    }
697
698    #[test]
699    fn broker_registration_with_endpoints_round_trip() {
700        let r = MetadataRecord::V1BrokerRegistration(BrokerRegistrationRecord {
701            node_id: NodeId(1),
702            broker_epoch: 0,
703            incarnation_id: Uuid::from_u128(0xfeedface_0000_0000_0000_000000000001),
704            host: "h".into(),
705            port: 9092,
706            rack: None,
707            log_dirs: vec![],
708            fenced: false,
709            in_controlled_shutdown: false,
710            cordoned_log_dirs: None,
711            endpoints: vec![BrokerEndpoint {
712                name: "EXTERNAL".into(),
713                host: "ext.example.com".into(),
714                port: 9092,
715                protocol: krabka_security::ListenerProtocol::SaslSsl,
716            }],
717            features: std::collections::BTreeMap::new(),
718        });
719        assert2::assert!(round_trip(&r) == r);
720    }
721
722    #[test]
723    fn delete_topic_round_trip() {
724        let r = MetadataRecord::V1DeleteTopic(DeleteTopicRecord {
725            name: "doomed".into(),
726        });
727        assert2::assert!(round_trip(&r) == r);
728    }
729
730    #[test]
731    fn unregister_broker_round_trip() {
732        let r = MetadataRecord::V1UnregisterBroker(UnregisterBrokerRecord {
733            node_id: NodeId(42),
734            broker_epoch: 17,
735        });
736        assert2::assert!(round_trip(&r) == r);
737    }
738
739    #[test]
740    fn broker_registration_change_and_unregister_controller_round_trip() {
741        for r in [
742            MetadataRecord::V1BrokerRegistrationChange(BrokerRegistrationChangeRecord {
743                node_id: NodeId(4),
744                broker_epoch: 12,
745                fenced: FencingChange::Unfence,
746                in_controlled_shutdown: true,
747                log_dirs: vec![Uuid::from_u128(0xD1)],
748                cordoned_log_dirs: Some(vec![]),
749            }),
750            MetadataRecord::V1UnregisterController(UnregisterControllerRecord {
751                node_id: NodeId(3000),
752            }),
753        ] {
754            assert2::assert!(round_trip(&r) == r);
755        }
756    }
757
758    /// `FencingChange` is Kafka's `BrokerRegistrationFencingChange`: -1
759    /// unfences, 0 leaves the state alone and 1 fences.
760    #[test]
761    fn fencing_change_matches_kafka() {
762        for (change, wire, from_false, from_true) in [
763            (FencingChange::Unfence, -1, false, false),
764            (FencingChange::None, 0, false, true),
765            (FencingChange::Fence, 1, true, true),
766        ] {
767            assert2::check!(
768                (
769                    change.wire_value(),
770                    FencingChange::from_wire(wire),
771                    change.apply(false),
772                    change.apply(true),
773                ) == (wire, Some(change), from_false, from_true)
774            );
775        }
776        for undefined in [-2, 2, i8::MIN, i8::MAX] {
777            assert2::check!(FencingChange::from_wire(undefined) == None);
778        }
779    }
780
781    /// `applied_to` is Kafka's `BrokerRegistration.cloneWith`: each field of
782    /// the change either leaves the registration's value or replaces it, and
783    /// controlled shutdown can only turn on.
784    #[test]
785    fn registration_change_applies_like_clone_with() {
786        let (d1, d2) = (Uuid::from_u128(0xD1), Uuid::from_u128(0xD2));
787        let current = BrokerRegistrationRecord {
788            node_id: NodeId(4),
789            broker_epoch: 12,
790            incarnation_id: Uuid::from_u128(4),
791            host: "broker-4".into(),
792            port: 9092,
793            rack: None,
794            endpoints: vec![],
795            log_dirs: vec![d1],
796            fenced: true,
797            in_controlled_shutdown: false,
798            cordoned_log_dirs: None,
799            features: std::collections::BTreeMap::new(),
800        };
801        let base = BrokerRegistrationChangeRecord::no_change(NodeId(4), 12);
802        let with = |edit: fn(&mut BrokerRegistrationRecord)| {
803            let mut want = current.clone();
804            edit(&mut want);
805            want
806        };
807        for (case, change, want) in [
808            ("no change", base.clone(), current.clone()),
809            (
810                "unfence",
811                BrokerRegistrationChangeRecord {
812                    fenced: FencingChange::Unfence,
813                    ..base.clone()
814                },
815                with(|b| b.fenced = false),
816            ),
817            (
818                "controlled shutdown",
819                BrokerRegistrationChangeRecord {
820                    in_controlled_shutdown: true,
821                    ..base.clone()
822                },
823                with(|b| b.in_controlled_shutdown = true),
824            ),
825            (
826                "new log dirs",
827                BrokerRegistrationChangeRecord {
828                    log_dirs: vec![d2],
829                    ..base.clone()
830                },
831                with(|b| b.log_dirs = vec![Uuid::from_u128(0xD2)]),
832            ),
833            (
834                "uncordon every dir",
835                BrokerRegistrationChangeRecord {
836                    cordoned_log_dirs: Some(vec![]),
837                    ..base.clone()
838                },
839                with(|b| b.cordoned_log_dirs = Some(vec![])),
840            ),
841        ] {
842            assert2::check!(change.applied_to(&current) == want, "{case}");
843        }
844
845        let shutting_down = BrokerRegistrationRecord {
846            in_controlled_shutdown: true,
847            ..current.clone()
848        };
849        assert2::check!(base.applied_to(&shutting_down) == shutting_down);
850    }
851
852    #[test]
853    fn topic_config_record_round_trip() {
854        let mut overrides = std::collections::BTreeMap::new();
855        overrides.insert("retention.ms".to_string(), "60000".to_string());
856        overrides.insert("segment.bytes".to_string(), "1048576".to_string());
857        let r = MetadataRecord::V1TopicConfig(TopicConfigRecord {
858            topic: "t".into(),
859            overrides,
860        });
861        assert2::assert!(round_trip(&r) == r);
862    }
863
864    #[test]
865    fn scram_credential_round_trip() {
866        let r = MetadataRecord::V1ScramCredential(ScramCredentialRecord {
867            user: "alice".into(),
868            mechanism: krabka_security::SaslMechanism::ScramSha512,
869            salt: vec![1u8; 16],
870            stored_key: vec![2u8; 64],
871            server_key: vec![3u8; 64],
872            iterations: 4096,
873        });
874        assert2::assert!(round_trip(&r) == r);
875    }
876
877    #[test]
878    fn delete_scram_credential_round_trip() {
879        let r = MetadataRecord::V1DeleteScramCredential(DeleteScramCredentialRecord {
880            user: "alice".into(),
881            mechanism: krabka_security::SaslMechanism::ScramSha512,
882        });
883        assert2::assert!(round_trip(&r) == r);
884    }
885
886    #[test]
887    fn v1_access_control_entry_round_trip() {
888        let entry = crate::AclEntry {
889            resource_type: crate::ResourceType::Topic,
890            resource_name: "foo".into(),
891            pattern_type: crate::PatternType::Literal,
892            principal: "User:alice".into(),
893            host: "*".into(),
894            operation: crate::AclOperation::Read,
895            permission_type: crate::PermissionType::Allow,
896        };
897        let r = MetadataRecord::V1AccessControlEntry(entry);
898        assert2::assert!(round_trip(&r) == r);
899    }
900
901    #[test]
902    fn v1_delete_access_control_entry_round_trip() {
903        let filter = crate::AclEntryFilter {
904            resource_type: Some(crate::ResourceType::Group),
905            resource_name: Some("cg-foo".into()),
906            pattern_type: Some(crate::PatternType::Literal),
907            principal: None,
908            host: None,
909            operation: None,
910            permission_type: None,
911        };
912        let r = MetadataRecord::V1DeleteAccessControlEntry(filter);
913        assert2::assert!(round_trip(&r) == r);
914    }
915
916    #[test]
917    fn broker_config_record_round_trip() {
918        let r = MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
919            node_id: NodeId(7),
920            config_name: "leader.replication.throttled.rate".into(),
921            config_value: Some("2048".into()),
922        });
923        assert2::assert!(round_trip(&r) == r);
924    }
925
926    #[test]
927    fn client_quota_record_round_trip() {
928        let r = MetadataRecord::V1ClientQuota(ClientQuotaRecord {
929            entity: vec![
930                QuotaEntity {
931                    entity_type: "client-id".into(),
932                    entity_name: Some("app1".into()),
933                },
934                QuotaEntity {
935                    entity_type: "user".into(),
936                    entity_name: Some("alice".into()),
937                },
938            ],
939            config_key: "producer_byte_rate".into(),
940            config_value: Some(1024.0),
941        });
942        assert2::assert!(round_trip(&r) == r);
943    }
944
945    #[test]
946    fn delegation_token_record_round_trip() {
947        let r = MetadataRecord::V1DelegationToken(DelegationTokenRecord {
948            token_id: "tok-abc".into(),
949            owner: krabka_security::KafkaPrincipal {
950                principal_type: "User".into(),
951                name: "alice".into(),
952            },
953            requester: krabka_security::KafkaPrincipal {
954                principal_type: "User".into(),
955                name: "admin".into(),
956            },
957            issue_timestamp_ms: 1_700_000_000_000,
958            expiry_timestamp_ms: 1_700_000_600_000,
959            max_timestamp_ms: 1_700_604_800_000,
960            renewers: vec![krabka_security::KafkaPrincipal {
961                principal_type: "User".into(),
962                name: "bob".into(),
963            }],
964        });
965        assert2::assert!(round_trip(&r) == r);
966    }
967
968    #[test]
969    fn delete_delegation_token_record_round_trip() {
970        let r = MetadataRecord::V1DeleteDelegationToken(DeleteDelegationTokenRecord {
971            token_id: "tok-abc".into(),
972        });
973        assert2::assert!(round_trip(&r) == r);
974    }
975
976    #[test]
977    fn voters_record_round_trips() {
978        let rec = MetadataRecord::V1Voters(VotersRecord {
979            voters: crate::voters::VoterSet::from_voters([crate::voters::Voter {
980                id: NodeId(7),
981                directory_id: uuid::Uuid::from_u128(7),
982                endpoints: vec![crate::voters::VoterEndpoint {
983                    name: "CONTROLLER".into(),
984                    host: "h".into(),
985                    port: 1,
986                }],
987                kraft_version: crate::voters::KRaftVersionRange::default(),
988            }]),
989        });
990        assert2::assert!(round_trip(&rec) == rec);
991    }
992
993    #[test]
994    fn kraft_version_record_round_trips() {
995        let rec = MetadataRecord::V1KRaftVersion(KRaftVersionRecord { kraft_version: 1 });
996        assert2::assert!(round_trip(&rec) == rec);
997    }
998
999    #[test]
1000    fn client_metrics_config_round_trip() {
1001        let mut overrides = std::collections::BTreeMap::new();
1002        overrides.insert("interval.ms".to_string(), "60000".to_string());
1003        overrides.insert(
1004            "metrics".to_string(),
1005            "org.apache.kafka.consumer.".to_string(),
1006        );
1007        let r = MetadataRecord::V1ClientMetricsConfig(ClientMetricsConfigRecord {
1008            name: "sub-a".into(),
1009            configs: overrides,
1010        });
1011        assert2::assert!(round_trip(&r) == r);
1012    }
1013
1014    #[test]
1015    fn topic_freeze_round_trip() {
1016        let r = MetadataRecord::V1TopicFreeze(crate::write_freeze::TopicFreezeRecord {
1017            scope: "tenant-a.".into(),
1018            pattern_type: crate::PatternType::Prefixed,
1019            frozen: true,
1020            reason: "DR cutover".into(),
1021            set_by: "User:alice".into(),
1022            set_at_ms: 1_700_000_000_000,
1023            proposal_id: Uuid::from_u128(0xB1),
1024            key_id: "alice-yubi".into(),
1025            signature: vec![0xAB; 64],
1026        });
1027        assert2::assert!(round_trip(&r) == r);
1028    }
1029
1030    #[test]
1031    fn break_glass_proposal_round_trip() {
1032        let r =
1033            MetadataRecord::V1BreakGlassProposal(crate::break_glass::BreakGlassProposalRecord {
1034                proposal_id: Uuid::from_u128(0xB1),
1035                action: crate::break_glass::BreakGlassAction::ThawTopicFreeze,
1036                target: "literal:orders".into(),
1037                proposer: "User:alice".into(),
1038                reason: "incident 4711 closed".into(),
1039                created_at_ms: 1_700_000_000_000,
1040                expires_at_ms: 1_700_000_600_000,
1041                approvals: vec![crate::break_glass::BreakGlassApproval {
1042                    principal: "User:bob".into(),
1043                    approved_at_ms: 1_700_000_060_000,
1044                    key_id: "bob-yubi".into(),
1045                    signature: vec![0xCD; 64],
1046                }],
1047                consumed_at_ms: 0,
1048                withdrawn: false,
1049            });
1050        assert2::assert!(round_trip(&r) == r);
1051    }
1052
1053    #[test]
1054    fn delete_break_glass_proposal_round_trip() {
1055        let r = MetadataRecord::V1DeleteBreakGlassProposal(Uuid::from_u128(0xB1));
1056        assert2::assert!(round_trip(&r) == r);
1057    }
1058
1059    #[test]
1060    fn partition_epoch_serde_default_is_minus_one() {
1061        // -1 is Kafka's "unknown epoch" sentinel; pin it (mutants flip it to
1062        // 0/1). This is the `#[serde(default)]` fallback for partition_epoch.
1063        assert2::assert!(default_partition_epoch() == -1);
1064    }
1065}