use std::collections::BTreeMap;
pub const TOLERANCE_PERCENT: u64 = 2;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Owed {
pub silent_since_ms: i64,
pub main_total_at_silence: u64,
pub twin_total: u64,
pub grow_to_bytes: u64,
pub resync_from: u64,
pub why: String,
}
impl Owed {
pub fn is_empty(&self) -> bool {
self.silent_since_ms == 0 && self.grow_to_bytes == 0 && self.resync_from == 0
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Book {
pub twins: BTreeMap<String, Owed>,
}
impl Book {
pub fn to_json(&self) -> Vec<u8> {
let twins: serde_json::Map<String, serde_json::Value> = self
.twins
.iter()
.filter(|(_, o)| !o.is_empty())
.map(|(p, o)| {
(
p.clone(),
serde_json::json!({
"silent_since_ms": o.silent_since_ms,
"main_total_at_silence": o.main_total_at_silence,
"twin_total": o.twin_total,
"grow_to_bytes": o.grow_to_bytes,
"resync_from": o.resync_from,
"why": o.why,
}),
)
})
.collect();
serde_json::to_vec_pretty(&serde_json::json!({ "v": 1, "twins": twins })).unwrap_or_default()
}
pub fn from_json(bytes: &[u8]) -> Result<Book, String> {
if bytes.is_empty() {
return Ok(Book::default());
}
let v: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| format!("twin-owed.json is not JSON: {e}"))?;
let mut book = Book::default();
if let Some(m) = v.get("twins").and_then(|t| t.as_object()) {
for (p, o) in m {
let u = |k: &str| o.get(k).and_then(|x| x.as_u64()).unwrap_or(0);
let owed = Owed {
silent_since_ms: o.get("silent_since_ms").and_then(|x| x.as_i64()).unwrap_or(0),
main_total_at_silence: u("main_total_at_silence"),
twin_total: u("twin_total"),
grow_to_bytes: u("grow_to_bytes"),
resync_from: u("resync_from"),
why: o.get("why").and_then(|x| x.as_str()).unwrap_or_default().to_owned(),
};
if !owed.is_empty() {
book.twins.insert(p.clone(), owed);
}
}
}
Ok(book)
}
}
pub fn clearly_above(a: u64, b: u64) -> bool {
u128::from(a) * 100 > u128::from(b) * u128::from(100 + TOLERANCE_PERCENT)
}
pub fn gib(bytes: u64) -> String {
format!("{:.1} GiB", bytes as f64 / (1u64 << 30) as f64)
}
pub fn follow_refusal(owed: &Owed, reported_total: u64) -> Option<String> {
if owed.grow_to_bytes == 0 || (reported_total > 0 && !clearly_above(owed.grow_to_bytes, reported_total)) {
return None;
}
let holds = if reported_total == 0 { "has not reported its volume since this primary started".to_owned() } else { format!("holds {}", gib(reported_total)) };
Some(format!(
"{} this twin's data volume {holds} and main's data set grew to {} while it was silent: it must be grown to at least that before it follows again, or it would fill part-way through main's data. It waits in `recovering` and keeps polling; nothing was sent. monetize's twin lane grows it when it reads heard (or grow its volume by hand); it follows the moment its report holds main's set",
crate::TWIN_TOO_SMALL,
gib(owed.grow_to_bytes)
))
}
#[cfg(test)]
mod tests {
use super::*;
const GIB: u64 = 1 << 30;
#[test]
fn a_book_survives_its_file_and_drops_what_is_owed_nothing() {
let mut b = Book::default();
b.twins.insert("gunnar-twin".into(), Owed { silent_since_ms: 5, main_total_at_silence: 20 * GIB, twin_total: 20 * GIB, grow_to_bytes: 30 * GIB, resync_from: 77, why: "x".into() });
b.twins.insert("quiet".into(), Owed::default());
let back = Book::from_json(&b.to_json()).unwrap();
assert_eq!(back.twins.len(), 1, "{back:?}");
assert_eq!(back.twins["gunnar-twin"], b.twins["gunnar-twin"]);
assert_eq!(Book::from_json(b"").unwrap(), Book::default());
assert!(Book::from_json(b"{not json").is_err());
}
#[test]
fn a_twin_owed_a_growth_is_refused_until_its_report_holds_mains_set() {
let owed = Owed { grow_to_bytes: 30 * GIB, ..Owed::default() };
let why = follow_refusal(&owed, 20 * GIB).expect("20 GiB behind a 30 GiB set is refused");
assert!(why.starts_with(crate::TWIN_TOO_SMALL) && why.contains("30.0 GiB") && why.contains("20.0 GiB") && why.contains("recovering"), "{why}");
assert!(follow_refusal(&owed, 0).is_some(), "an unreported volume has not shown the debt paid");
assert_eq!(follow_refusal(&owed, 30 * GIB), None);
assert_eq!(follow_refusal(&owed, 30 * GIB - GIB / 4), None, "a filesystem a little smaller than its volume is the same size");
assert_eq!(follow_refusal(&Owed::default(), GIB), None, "no debt, no refusal");
}
}