#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
clippy::string_slice,
clippy::arithmetic_side_effects,
)
)]
use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use serde::{Deserialize, Serialize};
use super::{ClusterInner, Incarnation};
pub const CELL_SEPARATOR: char = '#';
pub fn cell_key(node_id: &str, incarnation: Incarnation) -> String {
format!("{node_id}{CELL_SEPARATOR}{incarnation}")
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CounterShards {
#[serde(flatten)]
cells: BTreeMap<String, u64>,
}
impl CounterShards {
pub fn increment_cell(&mut self, cell: &str, by: u64) {
let tally = self.cells.entry(cell.to_owned()).or_default();
*tally = tally.saturating_add(by);
}
pub fn merge(&mut self, other: &Self) {
for (cell, &their_tally) in &other.cells {
self.cells
.entry(cell.clone())
.and_modify(|ours| *ours = (*ours).max(their_tally))
.or_insert(their_tally);
}
}
pub fn value(&self) -> u64 {
self.cells
.values()
.fold(0_u64, |total, &tally| total.saturating_add(tally))
}
#[cfg(test)]
pub fn cell_value(&self, cell: &str) -> u64 {
self.cells.get(cell).copied().unwrap_or(0)
}
#[cfg(test)]
pub fn cell_count(&self) -> usize {
self.cells.len()
}
}
#[derive(Clone)]
pub struct ClusterCounter {
inner: Arc<ClusterInner>,
name: String,
}
impl std::fmt::Debug for ClusterCounter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClusterCounter")
.field("name", &self.name)
.field("node_id", &self.inner.node_id)
.finish_non_exhaustive()
}
}
impl ClusterCounter {
pub(crate) const fn new(inner: Arc<ClusterInner>, name: String) -> Self {
Self { inner, name }
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
pub fn increment(&self) {
self.increment_by(1);
}
pub fn increment_by(&self, by: u64) {
let cell = cell_key(
&self.inner.node_id,
self.inner.incarnation.load(Ordering::Relaxed),
);
{
let mut state = self.inner.lock_state();
state
.counters
.entry(self.name.clone())
.or_default()
.increment_cell(&cell, by);
}
self.inner.notify.notify_one();
}
#[must_use]
pub fn get(&self) -> u64 {
self.inner
.lock_state()
.counters
.get(&self.name)
.map_or(0, CounterShards::value)
}
}
#[cfg(test)]
mod tests {
use super::{CounterShards, cell_key};
fn cells(entries: &[(&str, u64, u64)]) -> CounterShards {
let mut out = CounterShards::default();
for (node, incarnation, by) in entries {
out.increment_cell(&cell_key(node, *incarnation), *by);
}
out
}
fn merged(a: &CounterShards, b: &CounterShards) -> CounterShards {
let mut out = a.clone();
out.merge(b);
out
}
#[test]
fn merge_is_commutative() {
let a = cells(&[("node-a", 1, 3), ("node-shared", 1, 1)]);
let b = cells(&[("node-b", 1, 2), ("node-shared", 1, 4)]);
assert_eq!(
merged(&a, &b),
merged(&b, &a),
"merge(a, b) must equal merge(b, a)"
);
assert_eq!(
merged(&a, &b).value(),
9,
"the merged value must keep the per-cell maximum (3 + 2 + max(1, 4)); \
observed a={a:?} b={b:?}"
);
}
#[test]
fn merge_is_associative() {
let a = cells(&[("node-a", 1, 1)]);
let b = cells(&[("node-b", 1, 2)]);
let c = cells(&[("node-c", 1, 3)]);
assert_eq!(
merged(&merged(&a, &b), &c),
merged(&a, &merged(&b, &c)),
"merge must be associative"
);
assert_eq!(
merged(&merged(&a, &b), &c).value(),
6,
"the associatively merged value must be 1 + 2 + 3"
);
}
#[test]
fn merge_is_idempotent() {
let a = cells(&[("node-a", 1, 3), ("node-b", 1, 4)]);
assert_eq!(merged(&a, &a), a, "merge(a, a) must equal a");
assert_eq!(
a.value(),
7,
"the fixture must actually record its increments — an idempotence \
check over an empty map proves nothing; observed {a:?}"
);
}
#[test]
fn concurrent_shard_updates_sum_after_merge() {
let mut a = CounterShards::default();
for _ in 0..3 {
a.increment_cell(&cell_key("node-a", 1), 1);
}
let mut b = CounterShards::default();
for _ in 0..2 {
b.increment_cell(&cell_key("node-b", 1), 1);
}
assert_eq!(
merged(&a, &b).value(),
5,
"3 increments on A plus 2 on B must read 5 after merging B into A; \
observed a={a:?} b={b:?}"
);
assert_eq!(
merged(&b, &a).value(),
5,
"…and the same in the other merge direction; observed a={a:?} b={b:?}"
);
}
#[test]
fn restart_writes_a_fresh_cell_and_total_counts_both_boots() {
let peer_memory = cells(&[("node-a", 100, 10)]);
let after_restart = cells(&[("node-a", 200, 3)]);
assert_eq!(
after_restart.cell_value(&cell_key("node-a", 200)),
3,
"the restarted boot must write its own cell; observed {after_restart:?}"
);
assert_eq!(
after_restart.cell_value(&cell_key("node-a", 100)),
0,
"the restarted boot must NOT resume the previous boot's cell"
);
let converged = merged(&peer_memory, &after_restart);
assert_eq!(
converged.cell_count(),
2,
"both boots must survive the merge as distinct cells; observed {converged:?}"
);
assert_eq!(
converged.value(),
13,
"the total must count both boots (10 + 3) — if it reads 10 the \
post-restart increments were absorbed by per-cell max"
);
}
#[test]
fn merge_saturates_on_u64_overflow() {
let mut a = CounterShards::default();
a.increment_cell(&cell_key("node-a", 1), u64::MAX);
a.increment_cell(&cell_key("node-a", 1), 5);
let mut b = CounterShards::default();
b.increment_cell(&cell_key("node-b", 1), u64::MAX);
assert_eq!(
a.cell_value(&cell_key("node-a", 1)),
u64::MAX,
"a local increment past u64::MAX must saturate, not wrap or panic"
);
assert_eq!(
merged(&a, &b).value(),
u64::MAX,
"summing two saturated cells must saturate at u64::MAX"
);
}
}