use faucet_core::{FaucetError, shard::ShardSpec};
use std::collections::{HashMap, HashSet};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MemberShard {
pub members: usize,
pub member: usize,
}
impl MemberShard {
pub fn from_spec(spec: &ShardSpec) -> Option<Self> {
let d = &spec.descriptor;
Some(Self {
members: d.get("members")?.as_u64()? as usize,
member: d.get("member")?.as_u64()? as usize,
})
}
}
pub fn plan_member_shards(target: usize, partition_cap: Option<usize>) -> Vec<ShardSpec> {
let n = match partition_cap {
Some(cap) => target.min(cap.max(1)),
None => target,
};
if n <= 1 {
return vec![ShardSpec::whole()];
}
(0..n)
.map(|i| {
ShardSpec::new(
i.to_string(),
serde_json::json!({ "members": n, "member": i }),
)
})
.collect()
}
pub fn parse_member_shard(shard: &ShardSpec) -> Result<Option<MemberShard>, FaucetError> {
if shard.is_whole() {
return Ok(None);
}
MemberShard::from_spec(shard).map(Some).ok_or_else(|| {
FaucetError::Source(format!(
"kafka: invalid shard descriptor: {}",
shard.descriptor
))
})
}
pub fn member_seek_offset(bookmark: i64, committed: Option<i64>) -> Option<i64> {
match committed {
Some(c) if c >= bookmark => None,
_ => Some(bookmark),
}
}
pub fn commit_list(
offsets: &HashMap<(String, i32), i64>,
assigned: &HashSet<(String, i32)>,
) -> Vec<(String, i32, i64)> {
let mut out: Vec<(String, i32, i64)> = offsets
.iter()
.filter(|(tp, _)| assigned.contains(*tp))
.map(|((t, p), &o)| (t.clone(), *p, o))
.collect();
out.sort();
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn plan_member_shards_returns_target_slots() {
let shards = plan_member_shards(3, None);
assert_eq!(shards.len(), 3);
for (i, s) in shards.iter().enumerate() {
assert_eq!(s.id, i.to_string());
assert_eq!(s.descriptor["members"], 3);
assert_eq!(s.descriptor["member"], i);
assert_eq!(s.size_estimate, None, "member slots carry no size");
}
}
#[test]
fn plan_member_shards_caps_at_partition_count() {
let shards = plan_member_shards(8, Some(3));
assert_eq!(shards.len(), 3);
assert_eq!(shards[0].descriptor["members"], 3);
}
#[test]
fn plan_member_shards_uncapped_when_partition_count_unknown() {
let shards = plan_member_shards(4, None);
assert_eq!(shards.len(), 4);
}
#[test]
fn plan_member_shards_target_one_is_whole() {
for (target, cap) in [(1, None), (0, None), (5, Some(1)), (5, Some(0))] {
let shards = plan_member_shards(target, cap);
assert_eq!(shards.len(), 1, "target={target} cap={cap:?}");
assert!(shards[0].is_whole());
}
}
#[test]
fn member_shard_round_trips_plan_descriptors() {
for spec in plan_member_shards(5, None) {
let m = MemberShard::from_spec(&spec).unwrap();
assert_eq!(m.members, 5);
assert_eq!(m.member, spec.id.parse::<usize>().unwrap());
}
}
#[test]
fn parse_member_shard_whole_clears_and_real_parses() {
assert!(
parse_member_shard(&ShardSpec::whole())
.expect("whole parses")
.is_none()
);
let spec = &plan_member_shards(3, None)[2];
let m = parse_member_shard(spec).unwrap().unwrap();
assert_eq!((m.members, m.member), (3, 2));
}
#[test]
fn parse_member_shard_rejects_malformed_descriptor() {
for bad in [
ShardSpec::new("0", json!({ "member": 0 })),
ShardSpec::new("0", json!({ "members": 4 })),
ShardSpec::new("0", json!({ "members": "four", "member": 0 })),
] {
let err = parse_member_shard(&bad).unwrap_err();
assert!(err.to_string().contains("kafka"), "got: {err}");
}
}
#[test]
fn seek_when_committed_absent() {
assert_eq!(member_seek_offset(42, None), Some(42));
}
#[test]
fn seek_when_bookmark_ahead_of_committed() {
assert_eq!(member_seek_offset(100, Some(90)), Some(100));
}
#[test]
fn no_seek_when_committed_at_or_ahead() {
assert_eq!(member_seek_offset(100, Some(100)), None);
assert_eq!(member_seek_offset(100, Some(250)), None);
}
fn tp(t: &str, p: i32) -> (String, i32) {
(t.to_string(), p)
}
#[test]
fn commit_list_filters_to_assigned_and_sorts() {
let mut offsets = HashMap::new();
offsets.insert(tp("t", 1), 20);
offsets.insert(tp("t", 0), 10);
offsets.insert(tp("t", 2), 30); let assigned: HashSet<_> = [tp("t", 0), tp("t", 1)].into();
assert_eq!(
commit_list(&offsets, &assigned),
vec![("t".to_string(), 0, 10), ("t".to_string(), 1, 20)]
);
}
#[test]
fn commit_list_empty_when_nothing_assigned() {
let mut offsets = HashMap::new();
offsets.insert(tp("t", 0), 10);
assert!(commit_list(&offsets, &HashSet::new()).is_empty());
}
}