Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/stackable-operator/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ All notable changes to this project will be documented in this file.

- Support the annotation `secrets.stackable.tech/backend.autotls.cert.domain-components-in-subject-dn`
in the `SecretOperatorVolumeSourceBuilder` ([#1209]).
- Add `Role::fixed_replica_count` and `Role::estimated_replica_count` helper functions ([#1241]).

[#1209]: https://github.com/stackabletech/operator-rs/pull/1209
[#1241]: https://github.com/stackabletech/operator-rs/pull/1241

## [0.113.0] - 2026-06-22

Expand Down
115 changes: 115 additions & 0 deletions crates/stackable-operator/src/role_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,51 @@ where
}
}

impl<Config, ConfigOverrides, RoleConfig, CommonConfig>
Role<Config, ConfigOverrides, RoleConfig, CommonConfig>
where
RoleConfig: Default + JsonSchema + Serialize,
CommonConfig: Default + JsonSchema + Serialize,
ConfigOverrides: Default + JsonSchema + Serialize,
{
/// Returns [`Some<u32>`] in case the number of nodes is hard-coded to a certain value.
Comment thread
sbernauer marked this conversation as resolved.
Outdated
///
/// This is the case when all `replicas` are set to [`Some<u16>`], in which case they are simply
/// summed.
Comment thread
Techassi marked this conversation as resolved.
///
/// The argument `treat_zero_as_none` is a safety mechanism, which allows the caller to decide
/// if an explicit replica count of `0` should be treated as [`None`]. It also means that
/// [`None`] is returned in case no roleGroups are configured at all.
pub fn fixed_replica_count(&self, treat_zero_as_none: bool) -> Option<u32> {
// An empty role has no fixed replica count when zeros are treated as None.
if treat_zero_as_none && self.role_groups.is_empty() {
return None;
}

self.role_groups
.values()
.map(|rg| match rg.replicas {
None => None,
Some(0) if treat_zero_as_none => None,
// The individual replicas are [`u16`]s, so a [`u32`] sum has plenty of space.
Some(replicas) => Some(u32::from(replicas)),
})
.sum()
Comment thread
Techassi marked this conversation as resolved.
}

/// Returns the estimated total number of replicas across all role groups.
///
/// Unlike [`Self::fixed_replica_count`], this always returns a value: a role group with an unset
/// (i.e. [`None`]) replica count is assumed to run a single replica. Use this when a best-effort
/// estimate is needed even though the exact number of replicas is not hard-coded.
pub fn estimated_replica_count(&self) -> u32 {
self.role_groups
.values()
.map(|rg| u32::from(rg.replicas.unwrap_or(1)))
.sum()
}
}

impl<Config, ConfigOverrides, RoleConfig>
Role<Config, ConfigOverrides, RoleConfig, JavaCommonConfig>
where
Expand Down Expand Up @@ -654,4 +699,74 @@ mod tests {
]
);
}

#[test]
fn replica_counts_with_all_replicas_set() {
let role = construct_role_with_replicas([Some(3), Some(2), Some(5)]);

assert_eq!(role.fixed_replica_count(false), Some(10));
Comment thread
sbernauer marked this conversation as resolved.
Outdated
assert_eq!(role.fixed_replica_count(true), Some(10));
assert_eq!(role.estimated_replica_count(), 10);
}

#[test]
fn replica_counts_with_one_replica_unset() {
let role = construct_role_with_replicas([Some(3), None, Some(2)]);

assert_eq!(role.fixed_replica_count(false), None);
assert_eq!(role.fixed_replica_count(true), None);
assert_eq!(role.estimated_replica_count(), 6);
}

#[test]
fn replica_counts_with_a_zero_replica() {
let role = construct_role_with_replicas([Some(3), Some(0)]);

assert_eq!(role.fixed_replica_count(false), Some(3));
// With treat_zero_as_none the zero turns the whole count into None.
assert_eq!(role.fixed_replica_count(true), None);
assert_eq!(role.estimated_replica_count(), 3);
}

#[test]
fn replica_counts_with_a_single_zero_role_group() {
let role = construct_role_with_replicas([Some(0)]);

assert_eq!(role.fixed_replica_count(false), Some(0));
assert_eq!(role.fixed_replica_count(true), None);
assert_eq!(role.estimated_replica_count(), 0);
}

#[test]
fn replica_counts_without_role_groups() {
let role = construct_role_with_replicas(vec![]);

assert_eq!(role.fixed_replica_count(false), Some(0));
assert_eq!(role.fixed_replica_count(true), None);
assert_eq!(role.estimated_replica_count(), 0);
}

/// Builds a [`Role`] with one role group per passed `replicas` entry, so tests only need to
/// care about the replica counts that [`Role::fixed_replica_count`] operates on.
fn construct_role_with_replicas(
replicas: impl IntoIterator<Item = Option<u16>>,
) -> Role<(), EmptyConfigOverrides, GenericRoleConfig, GenericCommonConfig> {
Role {
config: CommonConfiguration::default(),
role_config: GenericRoleConfig::default(),
role_groups: replicas
.into_iter()
.enumerate()
.map(|(index, replicas)| {
(
format!("role-group-{index}"),
RoleGroup {
config: CommonConfiguration::default(),
replicas,
},
)
})
.collect(),
}
}
}