Skip to main content

krabka_metadata/
krabka_version.rs

1//! The krabka-owned `krabka.version` feature (KIP-584).
2//!
3//! From krabka-broker 1.0.0 on, `krabka.version` gates every krabka-only
4//! on-disk and inter-node format change, as `metadata.version` does for
5//! Kafka's. `metadata.version` mirrors Kafka's `MetadataVersion` table level
6//! for level, so krabka cannot add a level there. A broker keeps writing and
7//! sending the old format until the operator finalizes the `krabka.version`
8//! level that introduces the new one.
9//!
10//! # Levels
11//!
12//! - **Level 0** means exactly the krabka-broker 1.0.0 formats. An image that
13//!   has no finalized `krabka.version` reads as level 0, as Kafka treats an
14//!   absent feature (see [`KrabkaVersion::finalized`]).
15//! - **Level 1** also means exactly the 1.0.0 formats. It carries no format
16//!   change, and it never will: a 1.0.0 node advertises support for it, so a
17//!   later release cannot give it a meaning that 1.0.0 nodes do not know. The
18//!   first krabka-only format change takes level 2.
19//! - **Level 2** turns on diskless WAL object reservations
20//!   ([`DISKLESS_OBJECT_RESERVATION_LEVEL`]). A broker records a reservation
21//!   for a WAL object in `__diskless_wal_index` before it uploads the object,
22//!   and clears it once the object's index records are applied, so no broker
23//!   reclaims an object whose index publication is still in flight. The
24//!   reservation reuses the 1.x delete-floor record encoding, so every 1.x
25//!   reader can still replay it. Below level 2 a broker writes no
26//!   reservations. Level 2 changes no private RPC version.
27//! - **Level 3** turns on two formats. Level 2 shipped in krabka-broker 1.1.3,
28//!   so a later format cannot join it.
29//!   - The OCSF 1.3.0 activity ids of `__krabka_audit` records
30//!     ([`OCSF_ACTIVITY_IDS_LEVEL`]). Application Lifecycle reports a start as
31//!     activity 3 (Start) and a stop as 4 (Stop), Authorize Session reports an
32//!     ACL denial as 99 (Other), API Activity reports an admin operation as
33//!     Create, Read, Update or Delete where one applies, and a record carries
34//!     `metadata.log_version` `"2"`. Below level 3 a broker writes the 1.0.0
35//!     shape. `krabka-audit verify` hashes each record's bytes, so a chain
36//!     that mixes both shapes still verifies.
37//!   - The normalized tiered-storage key prefix
38//!     ([`TIERED_KEY_PREFIX_NORMALIZED_LEVEL`]). A broker trims spaces and
39//!     slashes from the configured prefix before it builds a segment key, as
40//!     it already does for diskless WAL objects. Below level 3 it writes the
41//!     key with the prefix exactly as configured. A reader looks a segment up
42//!     under both forms at every level.
43//!
44//!   Level 3 changes no private RPC version.
45//!
46//! # Why the maximum is 1, not 0
47//!
48//! Kafka does not advertise a feature whose highest supported level is 0.
49//! `BrokerFeatures.defaultSupportedFeatures` and
50//! `QuorumFeatures.defaultSupportedFeatureMap` both add a feature to the
51//! supported map only when `maxVersion > 0`, and the controller treats a
52//! feature that a node does not advertise as supported at `[0, 0]`. A feature
53//! with a maximum of 0 therefore cannot be finalized above 0, and
54//! `kafka-features describe`, which lists `supportedFeatures`, would show it
55//! only because krabka breaks that rule. With a maximum of 1, `krabka.version`
56//! appears in `kafka-features describe`, and
57//! `kafka-features upgrade --feature krabka.version=1` finalizes it.
58//! `kafka-features upgrade --release-version` iterates Kafka's own
59//! `Feature.PRODUCTION_FEATURES` only, so it never touches `krabka.version`.
60//!
61//! # Why a fresh cluster bootstraps at 0
62//!
63//! A fresh cluster bootstraps `krabka.version` at [`KRABKA_VERSION_MIN`],
64//! whatever the bootstrap `metadata.version` is, so `krabka format` writes no
65//! `FeatureLevelRecord` for it, as Kafka writes none for a feature at level 0.
66//! An Apache Kafka node in the same cluster does not know `krabka.version`, so
67//! it treats the feature as supported at `[0, 0]` (`QuorumFeatures.DISABLED`).
68//! A Kafka controller that replays a finalized level above 0 halts: it throws
69//! "this controller only supports versions 0", and its
70//! `ProcessTerminatingFaultHandler` exits. A Kafka broker cannot register with
71//! a controller that has finalized a level above 0.
72//!
73//! This departs from Kafka's rule for its own production features, which
74//! bootstrap at their latest level. That rule holds because every Kafka node
75//! of the release knows those features. `krabka.version` is not one of Kafka's
76//! features, so no Kafka node ever knows it. Nothing is lost: levels 0 and 1
77//! mean the same formats.
78//!
79//! An operator finalizes `krabka.version` with
80//! `kafka-features upgrade --feature krabka.version=1` once every node supports
81//! it. The controller refuses a level that some registered node does not
82//! support, and a Kafka node, which advertises nothing for the feature,
83//! supports only 0.
84//!
85//! # Dependencies
86//!
87//! `krabka.version` declares no KIP-1022 dependency on `metadata.version`. A
88//! future level that needs a Kafka record shape adds one for that level only.
89//!
90//! # Private-RPC version negotiation
91//!
92//! [`private_rpc_version`] is the negotiation mechanism for the krabka-private
93//! controller RPCs, the same one Kafka uses for inter-broker protocol versions
94//! through `metadata.version`. The controller finalizes a level only once every
95//! registered node advertises support for it. The finalized level therefore
96//! tells a sender which request versions every peer serves, and a sender never
97//! sends a version above the one the table gives for that level.
98
99use crate::MetadataImage;
100
101/// KIP-584 feature name.
102pub const KRABKA_VERSION_FEATURE: &str = "krabka.version";
103
104/// Minimum supported level: the krabka-broker 1.0.0 formats.
105pub const KRABKA_VERSION_MIN: i16 = 0;
106
107/// Maximum supported level, and the latest production level. Level 1 means
108/// the same formats as level 0. See the module docs for the meaning of each
109/// level.
110pub const KRABKA_VERSION_MAX: i16 = 3;
111
112/// The first level at which a broker writes diskless WAL object reservations
113/// before it uploads an object. See the module docs.
114pub const DISKLESS_OBJECT_RESERVATION_LEVEL: i16 = 2;
115
116/// The first level at which a broker writes `__krabka_audit` records with the
117/// OCSF 1.3.0 activity ids. See the module docs.
118pub const OCSF_ACTIVITY_IDS_LEVEL: i16 = 3;
119
120/// The first level at which a broker normalizes the configured tiered-storage
121/// prefix before it builds a segment key. See the module docs.
122pub const TIERED_KEY_PREFIX_NORMALIZED_LEVEL: i16 = 3;
123
124/// The finalized `krabka.version` level of a metadata image.
125#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
126pub struct KrabkaVersion(i16);
127
128impl KrabkaVersion {
129    /// Wrap a raw level.
130    #[must_use]
131    pub const fn new(level: i16) -> Self {
132        Self(level)
133    }
134
135    /// The finalized `krabka.version` of `image`. An image that has not
136    /// finalized the feature reads as level 0, as Kafka treats an absent
137    /// feature.
138    #[must_use]
139    pub fn finalized(image: &MetadataImage) -> Self {
140        Self(
141            image
142                .finalized_feature(KRABKA_VERSION_FEATURE)
143                .unwrap_or(KRABKA_VERSION_MIN),
144        )
145    }
146
147    /// The raw level.
148    #[must_use]
149    pub const fn level(self) -> i16 {
150        self.0
151    }
152
153    /// The highest request version of `rpc` that a node may send at this
154    /// level. See [`private_rpc_version`].
155    #[must_use]
156    pub const fn max_request_version(self, rpc: PrivateRpc) -> Option<i16> {
157        rpc.max_request_version(self.0)
158    }
159}
160
161/// A krabka-private controller RPC. Its api key sits at 1000 or above, so a
162/// later Kafka assignment cannot collide with it.
163#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
164pub enum PrivateRpc {
165    /// `SubmitChange`, api key 1003: a node forwards a metadata change to the
166    /// controller leader.
167    SubmitChange,
168    /// `MetadataFetch`, api key 1004: an observer fetches the metadata log.
169    MetadataFetch,
170    /// `DelegationTokenMutation`, api key 1005: a guarded forward of a
171    /// delegation-token change.
172    DelegationTokenMutation,
173}
174
175impl PrivateRpc {
176    /// Every private RPC, in api-key order.
177    pub const ALL: [Self; 3] = [
178        Self::SubmitChange,
179        Self::MetadataFetch,
180        Self::DelegationTokenMutation,
181    ];
182
183    /// The api key of this RPC.
184    #[must_use]
185    pub const fn api_key(self) -> i16 {
186        match self {
187            Self::SubmitChange => 1003,
188            Self::MetadataFetch => 1004,
189            Self::DelegationTokenMutation => 1005,
190        }
191    }
192
193    /// The private RPC with `api_key`, or `None` for any other key.
194    #[must_use]
195    pub const fn from_api_key(api_key: i16) -> Option<Self> {
196        match api_key {
197            1003 => Some(Self::SubmitChange),
198            1004 => Some(Self::MetadataFetch),
199            1005 => Some(Self::DelegationTokenMutation),
200            _ => None,
201        }
202    }
203
204    /// The highest request version of this RPC that a node may send when the
205    /// finalized `krabka.version` is `krabka_version`. `None` for a level
206    /// outside `[KRABKA_VERSION_MIN, KRABKA_VERSION_MAX]`: no node of this
207    /// build can see such a level finalized, because the controller finalizes
208    /// only a level that every node supports.
209    #[must_use]
210    pub const fn max_request_version(self, krabka_version: i16) -> Option<i16> {
211        if krabka_version < KRABKA_VERSION_MIN || krabka_version > KRABKA_VERSION_MAX {
212            return None;
213        }
214        // Levels 0 and 1 are both the 1.0.0 formats, and levels 2 and 3
215        // change no RPC, so every RPC is v0. A level that raises an RPC's version adds
216        // an arm here.
217        Some(match self {
218            Self::SubmitChange | Self::MetadataFetch | Self::DelegationTokenMutation => 0,
219        })
220    }
221}
222
223/// The highest request version of the private controller RPC `api_key` that a
224/// node may send when the finalized `krabka.version` is `krabka_version`.
225///
226/// Returns `None` for an api key that is not a private controller RPC, and for
227/// a level this build does not support. At levels 0 to 3 every RPC is v0.
228#[must_use]
229pub const fn private_rpc_version(api_key: i16, krabka_version: i16) -> Option<i16> {
230    match PrivateRpc::from_api_key(api_key) {
231        Some(rpc) => rpc.max_request_version(krabka_version),
232        None => None,
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use assert2::check;
239
240    use super::*;
241    use crate::{FeatureLevelRecord, MetadataRecord};
242
243    fn image_at(level: Option<i16>) -> MetadataImage {
244        let mut image = MetadataImage::new(uuid::Uuid::nil());
245        if let Some(level) = level {
246            image.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
247                name: KRABKA_VERSION_FEATURE.into(),
248                level,
249            }));
250        }
251        image
252    }
253
254    #[test]
255    fn constants_are_pinned() {
256        check!(
257            (
258                KRABKA_VERSION_FEATURE,
259                KRABKA_VERSION_MIN,
260                KRABKA_VERSION_MAX,
261                DISKLESS_OBJECT_RESERVATION_LEVEL,
262                OCSF_ACTIVITY_IDS_LEVEL,
263                TIERED_KEY_PREFIX_NORMALIZED_LEVEL,
264            ) == ("krabka.version", 0, 3, 2, 3, 3)
265        );
266    }
267
268    #[test]
269    fn finalized_reads_the_image() {
270        for (case, finalized, want) in [
271            ("absent reads as 0", None, KrabkaVersion::new(0)),
272            // A level-0 record deletes the entry, so it reads as absent.
273            ("explicit 0", Some(0), KrabkaVersion::new(0)),
274            (
275                "maximum",
276                Some(KRABKA_VERSION_MAX),
277                KrabkaVersion::new(KRABKA_VERSION_MAX),
278            ),
279        ] {
280            check!(
281                KrabkaVersion::finalized(&image_at(finalized)) == want,
282                "{case}"
283            );
284        }
285    }
286
287    #[test]
288    fn finalized_ignores_other_features() {
289        let mut image = image_at(None);
290        image.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
291            name: "transaction.version".into(),
292            level: 2,
293        }));
294        check!(KrabkaVersion::finalized(&image).level() == 0);
295    }
296
297    /// One row per private RPC. The match has no wildcard arm, so a new
298    /// `PrivateRpc` variant does not compile until it has a row here.
299    #[test]
300    fn private_rpc_version_table() {
301        for rpc in PrivateRpc::ALL {
302            // (api key, [(krabka.version, highest request version)])
303            let (api_key, rows): (i16, [(i16, Option<i16>); 6]) = match rpc {
304                PrivateRpc::SubmitChange => (
305                    1003,
306                    [
307                        (-1, None),
308                        (0, Some(0)),
309                        (1, Some(0)),
310                        (2, Some(0)),
311                        (3, Some(0)),
312                        (4, None),
313                    ],
314                ),
315                PrivateRpc::MetadataFetch => (
316                    1004,
317                    [
318                        (-1, None),
319                        (0, Some(0)),
320                        (1, Some(0)),
321                        (2, Some(0)),
322                        (3, Some(0)),
323                        (4, None),
324                    ],
325                ),
326                PrivateRpc::DelegationTokenMutation => (
327                    1005,
328                    [
329                        (-1, None),
330                        (0, Some(0)),
331                        (1, Some(0)),
332                        (2, Some(0)),
333                        (3, Some(0)),
334                        (4, None),
335                    ],
336                ),
337            };
338            check!(rpc.api_key() == api_key);
339            check!(PrivateRpc::from_api_key(api_key) == Some(rpc));
340            for (level, want) in rows {
341                check!(
342                    private_rpc_version(api_key, level) == want,
343                    "{rpc:?} at {level}"
344                );
345                check!(
346                    KrabkaVersion::new(level).max_request_version(rpc) == want,
347                    "{rpc:?} at {level}"
348                );
349            }
350        }
351    }
352
353    #[test]
354    fn private_rpc_version_refuses_other_api_keys() {
355        for api_key in [0, 18, 1000, 1001, 1002, 1006, -1] {
356            check!(
357                private_rpc_version(api_key, 0).is_none(),
358                "api key {api_key}"
359            );
360        }
361    }
362}