use std::collections::HashMap;
use std::sync::{Arc, PoisonError, RwLock};
use std::time::{Duration, Instant};
use serde::Serialize;
use crate::config::{GalaxySettings, Peer};
use crate::maintenance::Shutdown;
const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
const FORWARD_TIMEOUT: Duration = Duration::from_secs(20);
pub const HOPS_HEADER: &str = "X-SightingDB-Hops";
pub const ORIGIN_HEADER: &str = "X-SightingDB-Origin";
#[derive(Debug, Clone, Serialize)]
pub struct PeerHealth {
pub url: String,
pub online: bool,
pub probed: bool,
pub last_seen: i64,
pub latency_ms: Option<u64>,
pub version: Option<String>,
pub error: Option<String>,
pub failures: u32,
pub catching_up: bool,
}
impl PeerHealth {
pub fn unprobed(url: &str) -> Self {
PeerHealth {
url: url.to_string(),
online: false,
probed: false,
last_seen: 0,
latency_ms: None,
version: None,
error: None,
failures: 0,
catching_up: false,
}
}
}
#[derive(Debug)]
pub struct Galaxy {
peers: RwLock<Vec<Peer>>,
max_hops: u8,
health_interval: u64,
sync_interval: u64,
reconcile_interval: u64,
gossip_interval: u64,
acl_authority: bool,
verify_tls: bool,
health: RwLock<HashMap<String, PeerHealth>>,
fixed: Vec<String>,
}
const FORWARD_BODY_LIMIT: usize = 64 * 1024 * 1024;
#[derive(Debug)]
pub enum ForwardError {
NoHolder,
AllDown { namespace: String },
TooManyHops,
Unreachable(String),
}
impl std::fmt::Display for ForwardError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ForwardError::NoHolder => {
write!(f, "no server in this galaxy stores that namespace")
}
ForwardError::AllDown { namespace } => {
write!(
f,
"every server storing '{namespace}' is unreachable from here"
)
}
ForwardError::TooManyHops => write!(
f,
"the hop limit was reached: the galaxy is wired in a loop, or is deeper than max_hops allows"
),
ForwardError::Unreachable(e) => write!(f, "could not reach the server holding it: {e}"),
}
}
}
impl Galaxy {
pub fn new(settings: &GalaxySettings) -> Self {
let health = settings
.peers
.iter()
.map(|peer| (peer.url.clone(), PeerHealth::unprobed(&peer.url)))
.collect();
Galaxy {
peers: RwLock::new(settings.peers.clone()),
fixed: settings.fixed.clone(),
max_hops: settings.max_hops,
health_interval: settings.health_interval,
sync_interval: settings.sync_interval,
reconcile_interval: settings.reconcile_interval,
gossip_interval: settings.gossip_interval,
acl_authority: settings.acl_authority,
verify_tls: settings.verify_tls,
health: RwLock::new(health),
}
}
pub fn max_hops(&self) -> u8 {
self.max_hops
}
pub fn peers(&self) -> Vec<Peer> {
self.peers
.read()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
pub fn holders(&self, namespace: &str) -> Vec<Peer> {
self.peers
.read()
.unwrap_or_else(PoisonError::into_inner)
.iter()
.filter(|peer| peer.stores.holds(namespace))
.cloned()
.collect()
}
pub fn live_holders(&self, namespace: &str) -> Vec<Peer> {
let health = self.health.read().unwrap_or_else(PoisonError::into_inner);
self.holders(namespace)
.into_iter()
.filter(|peer| {
health
.get(&peer.url)
.is_none_or(|known| known.online || !known.probed)
})
.collect()
}
pub fn reader_for(&self, namespace: &str, value: &str) -> Option<Peer> {
let behind: Vec<String> = {
let health = self.health.read().unwrap_or_else(PoisonError::into_inner);
health
.values()
.filter(|known| known.catching_up)
.map(|known| known.url.clone())
.collect()
};
let live = self.live_holders(namespace);
let current: Vec<Peer> = live
.iter()
.filter(|peer| !behind.contains(&peer.url))
.cloned()
.collect();
let choose_from = if current.is_empty() { live } else { current };
choose_from
.into_iter()
.max_by_key(|peer| weigh(value, &peer.url))
}
pub fn health(&self) -> Vec<PeerHealth> {
let health = self.health.read().unwrap_or_else(PoisonError::into_inner);
self.peers()
.iter()
.map(|peer| {
health
.get(&peer.url)
.cloned()
.unwrap_or_else(|| PeerHealth::unprobed(&peer.url))
})
.collect()
}
fn record(&self, url: &str, outcome: Result<Probed, String>) {
let mut health = self.health.write().unwrap_or_else(PoisonError::into_inner);
let entry = health
.entry(url.to_string())
.or_insert_with(|| PeerHealth::unprobed(url));
entry.probed = true;
match outcome {
Ok(probed) => {
if !entry.online && entry.failures > 0 {
log::info!("Galaxy peer {url} is answering again");
}
if probed.catching_up && !entry.catching_up {
log::info!("Galaxy peer {url} is catching up; reads will go elsewhere");
} else if !probed.catching_up && entry.catching_up {
log::info!("Galaxy peer {url} has caught up");
}
entry.online = true;
entry.last_seen = chrono::Utc::now().timestamp();
entry.latency_ms = Some(probed.latency_ms);
entry.version = probed.version;
entry.error = None;
entry.failures = 0;
entry.catching_up = probed.catching_up;
}
Err(error) => {
if entry.online || !entry.probed || entry.failures == 0 {
log::warn!("Galaxy peer {url} is not answering: {error}");
}
entry.online = false;
entry.latency_ms = None;
entry.error = Some(error);
entry.failures = entry.failures.saturating_add(1);
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AddPeer {
Invalid(String),
AlreadyThere(String),
}
impl std::fmt::Display for AddPeer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AddPeer::Invalid(message) | AddPeer::AlreadyThere(message) => write!(f, "{message}"),
}
}
}
fn is_own_address(url: &str, own_listen: &str) -> bool {
let host_port = url
.trim_start_matches("https://")
.trim_start_matches("http://")
.trim_end_matches('/');
if host_port == own_listen {
return true;
}
let Some((own_host, own_port)) = own_listen.rsplit_once(':') else {
return false;
};
let Some((host, port)) = host_port.rsplit_once(':') else {
return false;
};
port == own_port
&& matches!(own_host, "0.0.0.0" | "[::]" | "::")
&& matches!(host, "127.0.0.1" | "localhost" | "[::1]" | "::1")
}
fn weigh(value: &str, url: &str) -> u64 {
use sha2::Digest;
let mut hasher = sha2::Sha256::new();
hasher.update(value.as_bytes());
hasher.update(b":");
hasher.update(url.as_bytes());
let digest = hasher.finalize();
u64::from_be_bytes(digest[..8].try_into().unwrap_or([0; 8]))
}
pub fn origin_of(req: &actix_web::HttpRequest) -> Option<String> {
req.headers().get(HOPS_HEADER)?;
let origin = req.headers().get(ORIGIN_HEADER)?.to_str().ok()?.trim();
(!origin.is_empty()).then(|| origin.to_string())
}
pub fn hops_left(req: &actix_web::HttpRequest, max: u8) -> Option<u8> {
match req.headers().get(HOPS_HEADER) {
None => Some(max),
Some(value) => {
let left: u8 = value.to_str().ok()?.trim().parse().ok()?;
(left > 0).then_some(left)
}
}
}
pub async fn run(state: Arc<crate::handlers::SharedState>, shutdown: Arc<Shutdown>) {
let Some(galaxy) = state.galaxy.as_ref() else {
return;
};
if galaxy.peers().is_empty() {
return;
}
let Some(client) = client(galaxy.verify_tls) else {
log::error!("Galaxy health checks disabled: no HTTP client could be built");
return;
};
log::info!(
"Galaxy health checks every {}s for {} peer(s){}",
galaxy.health_interval,
galaxy.peers().len(),
if galaxy.verify_tls {
""
} else {
" (TLS verification off)"
}
);
let period = Duration::from_secs(galaxy.health_interval);
loop {
for peer in &galaxy.peers() {
if shutdown.is_stopped() {
return;
}
let outcome = probe(&client, &peer.url).await;
galaxy.record(&peer.url, outcome);
}
if shutdown.is_stopped() {
return;
}
actix_web::rt::time::sleep(period).await;
}
}
pub struct Forwarded {
pub status: u16,
pub body: Vec<u8>,
}
impl Galaxy {
async fn send(
&self,
peer: &Peer,
method: awc::http::Method,
path: &str,
body: Option<&[u8]>,
hops_left: u8,
origin: &str,
) -> Result<Forwarded, ForwardError> {
let client = client(self.verify_tls).ok_or_else(|| {
ForwardError::Unreachable("no HTTP client could be built".to_string())
})?;
let mut request = client
.request(method, format!("{}{path}", peer.url))
.timeout(FORWARD_TIMEOUT)
.insert_header(("Authorization", peer.key.as_str()))
.insert_header((HOPS_HEADER, (hops_left - 1).to_string()))
.insert_header((ORIGIN_HEADER, origin));
let mut response = match body {
Some(bytes) => {
request = request.insert_header(("Content-Type", "application/json"));
request.send_body(bytes.to_vec()).await
}
None => request.send().await,
}
.map_err(|e| ForwardError::Unreachable(e.to_string()))?;
let status = response.status().as_u16();
let body = response
.body()
.limit(FORWARD_BODY_LIMIT)
.await
.map_err(|e| ForwardError::Unreachable(e.to_string()))?
.to_vec();
Ok(Forwarded { status, body })
}
pub async fn forward_read(
&self,
namespace: &str,
value: &str,
path: &str,
hops_left: u8,
origin: &str,
) -> Result<Forwarded, ForwardError> {
if hops_left == 0 {
return Err(ForwardError::TooManyHops);
}
if self.holders(namespace).is_empty() {
return Err(ForwardError::NoHolder);
}
let peer = self
.reader_for(namespace, value)
.ok_or_else(|| ForwardError::AllDown {
namespace: namespace.to_string(),
})?;
self.send(&peer, awc::http::Method::GET, path, None, hops_left, origin)
.await
}
pub fn is_fixed(&self, url: &str) -> bool {
let url = url.trim().trim_end_matches('/');
self.fixed.iter().any(|known| known == url)
}
pub fn editable_peers(&self) -> Vec<Peer> {
self.peers()
.into_iter()
.filter(|peer| !self.is_fixed(&peer.url))
.collect()
}
pub fn add_peer(&self, peer: Peer, own_listen: &str) -> Result<Peer, AddPeer> {
let peer = Peer {
url: peer.url.trim().trim_end_matches('/').to_string(),
key: peer.key.trim().to_string(),
stores: peer.stores,
};
if !(peer.url.starts_with("http://") || peer.url.starts_with("https://")) {
return Err(AddPeer::Invalid(
"a peer URL starts with http:// or https://".to_string(),
));
}
if peer
.url
.trim_start_matches("https://")
.trim_start_matches("http://")
.is_empty()
{
return Err(AddPeer::Invalid("a peer URL needs a host".to_string()));
}
if peer.key.is_empty() {
return Err(AddPeer::Invalid(
"a peer needs the key this server will authenticate with there".to_string(),
));
}
if is_own_address(&peer.url, own_listen) {
return Err(AddPeer::Invalid(format!(
"{} is this server's own address, which would make it its own mirror",
peer.url
)));
}
let mut peers = self.peers.write().unwrap_or_else(PoisonError::into_inner);
if peers.iter().any(|known| known.url == peer.url) {
return Err(AddPeer::AlreadyThere(format!(
"{} is already in this galaxy",
peer.url
)));
}
if self.is_fixed(&peer.url) {
return Err(AddPeer::AlreadyThere(format!(
"{} is declared in the configuration file, so it cannot be changed here",
peer.url
)));
}
peers.push(peer.clone());
Ok(peer)
}
pub fn remove_peer(&self, url: &str) -> bool {
let url = url.trim().trim_end_matches('/');
let mut peers = self.peers.write().unwrap_or_else(PoisonError::into_inner);
let before = peers.len();
peers.retain(|peer| peer.url != url);
let removed = peers.len() != before;
drop(peers);
if removed {
self.health
.write()
.unwrap_or_else(PoisonError::into_inner)
.remove(url);
}
removed
}
pub fn update_peer(&self, peer: Peer) -> Option<Peer> {
let peer = Peer {
url: peer.url.trim().trim_end_matches('/').to_string(),
key: peer.key.trim().to_string(),
stores: peer.stores,
};
if peer.key.is_empty() {
return None;
}
let mut peers = self.peers.write().unwrap_or_else(PoisonError::into_inner);
match peers.iter_mut().find(|known| known.url == peer.url) {
Some(known) => {
*known = peer.clone();
Some(peer)
}
None => None,
}
}
pub async fn push_value(
&self,
namespace: &str,
value: &str,
state: &crate::attribute::Merge,
hops_left: u8,
) -> Vec<(String, Result<u16, String>)> {
let body = serde_json::to_vec(&serde_json::json!({
"items": [{
"namespace": namespace,
"value": value,
"counts": state.counts,
"stats": state.stats,
"first_seen": state.first_seen,
"last_seen": state.last_seen,
"tags": state.tags,
"tags_at": state.tags_at,
"ttl": state.ttl,
}]
}))
.unwrap_or_default();
let mut outcomes = Vec::new();
for peer in self.live_holders(namespace) {
let sent = self
.send(
&peer,
awc::http::Method::POST,
"/_api/merge",
Some(&body),
hops_left,
"",
)
.await;
outcomes.push((
peer.url.clone(),
match sent {
Ok(answer) if (200..300).contains(&answer.status) => Ok(answer.status),
Ok(answer) => Err(format!(
"answered {}: {}",
answer.status,
String::from_utf8_lossy(&answer.body)
)),
Err(e) => Err(e.to_string()),
},
));
}
outcomes
}
pub fn reader_url(&self, namespace: &str, value: &str) -> Result<String, ForwardError> {
if self.holders(namespace).is_empty() {
return Err(ForwardError::NoHolder);
}
self.reader_for(namespace, value)
.map(|peer| peer.url.clone())
.ok_or_else(|| ForwardError::AllDown {
namespace: namespace.to_string(),
})
}
pub async fn forward_to(
&self,
url: &str,
method: awc::http::Method,
path: &str,
body: Option<&[u8]>,
hops_left: u8,
origin: &str,
) -> Result<Forwarded, ForwardError> {
if hops_left == 0 {
return Err(ForwardError::TooManyHops);
}
let peer = self
.peers()
.into_iter()
.find(|peer| peer.url == url)
.ok_or_else(|| ForwardError::Unreachable(format!("{url} is not a configured peer")))?;
self.send(&peer, method, path, body, hops_left, origin)
.await
}
pub async fn forward_write(
&self,
namespace: &str,
method: awc::http::Method,
path: &str,
body: Option<&[u8]>,
hops_left: u8,
origin: &str,
) -> Result<Vec<(String, Forwarded)>, ForwardError> {
if hops_left == 0 {
return Err(ForwardError::TooManyHops);
}
if self.holders(namespace).is_empty() {
return Err(ForwardError::NoHolder);
}
let live: Vec<Peer> = self.live_holders(namespace);
if live.is_empty() {
return Err(ForwardError::AllDown {
namespace: namespace.to_string(),
});
}
let mut answers = Vec::with_capacity(live.len());
let mut last_error = None;
for peer in &live {
match self
.send(peer, method.clone(), path, body, hops_left, origin)
.await
{
Ok(answer) => answers.push((peer.url.clone(), answer)),
Err(e) => {
log::warn!("Forwarding to {} failed: {e}", peer.url);
last_error = Some(e);
}
}
}
if answers.is_empty() {
return Err(last_error.unwrap_or_else(|| ForwardError::AllDown {
namespace: namespace.to_string(),
}));
}
Ok(answers)
}
}
impl Galaxy {
pub async fn forward_batch(
&self,
namespaces: &[String],
path: &str,
body: &[u8],
hops_left: u8,
origin: &str,
) -> Result<Vec<(String, Forwarded)>, ForwardError> {
if hops_left == 0 {
return Err(ForwardError::TooManyHops);
}
let mut wanted: Vec<Peer> = Vec::new();
for namespace in namespaces {
for peer in self.live_holders(namespace) {
if !wanted.iter().any(|known| known.url == peer.url) {
wanted.push(peer.clone());
}
}
}
if wanted.is_empty() {
return Err(ForwardError::NoHolder);
}
let mut answers = Vec::with_capacity(wanted.len());
let mut last_error = None;
for peer in &wanted {
match self
.send(
peer,
awc::http::Method::POST,
path,
Some(body),
hops_left,
origin,
)
.await
{
Ok(answer) => answers.push((peer.url.clone(), answer)),
Err(e) => {
log::warn!("Forwarding a batch to {} failed: {e}", peer.url);
last_error = Some(e);
}
}
}
if answers.is_empty() {
return Err(last_error.unwrap_or(ForwardError::NoHolder));
}
Ok(answers)
}
}
impl Galaxy {
pub async fn walk(&self, hops_left: u8) -> Vec<(String, serde_json::Value)> {
if hops_left == 0 {
return Vec::new();
}
let mut found = Vec::new();
for peer in &self.peers() {
match self
.send(
peer,
awc::http::Method::GET,
"/_management/api/galaxy",
None,
hops_left,
"",
)
.await
{
Ok(answer) if (200..300).contains(&answer.status) => {
if let Ok(view) = serde_json::from_slice(&answer.body) {
found.push((peer.url.clone(), view));
}
}
_ => {}
}
}
found
}
}
pub async fn sync(state: Arc<crate::handlers::SharedState>, shutdown: Arc<Shutdown>) {
let Some(galaxy) = state.galaxy.as_ref() else {
return;
};
if galaxy.peers().is_empty() || galaxy.sync_interval == 0 {
state.set_catching_up(false);
return;
}
log::info!(
"Catching up from {} peer(s) every {}s",
galaxy.peers().len(),
galaxy.sync_interval
);
let period = Duration::from_secs(galaxy.sync_interval);
let mut first = true;
loop {
let report = galaxy.catch_up(&state, first).await;
if first {
state.set_catching_up(false);
first = false;
log::info!(
"Caught up: {} namespace(s) checked, {} value(s) taken from peers",
report.namespaces,
report.merged
);
} else if report.merged > 0 {
log::info!(
"Caught up on {} value(s) across {} namespace(s)",
report.merged,
report.namespaces
);
}
if nap(&shutdown, period).await {
return;
}
}
}
impl Galaxy {
pub async fn push_key(&self, entry: &serde_json::Value, hops_left: u8) -> Gossiped {
let body = serde_json::to_vec(entry).unwrap_or_default();
self.spread(
awc::http::Method::POST,
"/_management/api/keys",
Some(&body),
hops_left,
)
.await
}
pub async fn push_key_list(&self, keys: &serde_json::Value, hops_left: u8) -> Gossiped {
let body = serde_json::to_vec(&serde_json::json!({ "keys": keys })).unwrap_or_default();
self.spread(
awc::http::Method::PUT,
"/_management/api/keys",
Some(&body),
hops_left,
)
.await
}
pub fn owns_the_acl(&self) -> bool {
self.acl_authority
}
pub async fn push_tier(&self, change: &serde_json::Value, hops_left: u8) -> Gossiped {
let body = serde_json::to_vec(change).unwrap_or_default();
self.spread(
awc::http::Method::POST,
"/_management/api/tier",
Some(&body),
hops_left,
)
.await
}
pub async fn push_key_removal(&self, key: &str, hops_left: u8) -> Gossiped {
let path = format!("/_management/api/keys/{}", urlencoding_of(key));
self.spread(awc::http::Method::DELETE, &path, None, hops_left)
.await
}
async fn spread(
&self,
method: awc::http::Method,
path: &str,
body: Option<&[u8]>,
hops_left: u8,
) -> Gossiped {
let mut report = Gossiped::default();
if hops_left == 0 {
return report;
}
for peer in &self.peers() {
match self
.send(peer, method.clone(), path, body, hops_left, "")
.await
{
Ok(answer) if (200..300).contains(&answer.status) => report.accepted += 1,
Ok(answer) if answer.status == 403 || answer.status == 401 => {
report.refused += 1;
log::debug!(
"{} does not let this server change its keys ({})",
peer.url,
answer.status
);
}
Ok(answer) => {
report.failed += 1;
log::warn!(
"{} refused a key change: {} {}",
peer.url,
answer.status,
String::from_utf8_lossy(&answer.body).trim()
);
}
Err(e) => {
report.failed += 1;
log::warn!("Could not reach {} with a key change: {e}", peer.url);
}
}
}
report
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct Gossiped {
pub accepted: usize,
pub refused: usize,
pub failed: usize,
}
pub fn urlencoding_of(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for byte in value.as_bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(*byte as char);
}
other => out.push_str(&format!("%{other:02X}")),
}
}
out
}
impl Galaxy {
pub async fn peer_keys(&self) -> Vec<PeerKeys> {
let known = self.peers();
let mut found = Vec::with_capacity(known.len());
for peer in &self.peers() {
let mut row = PeerKeys {
url: peer.url.clone(),
keys: Vec::new(),
readable: false,
error: None,
};
match self
.send(
peer,
awc::http::Method::GET,
"/_management/api/keys",
None,
1,
"",
)
.await
{
Ok(answer) if (200..300).contains(&answer.status) => {
row.readable = true;
row.keys = serde_json::from_slice::<Vec<serde_json::Value>>(&answer.body)
.map(|list| {
list.iter()
.filter_map(|k| k.get("key")?.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
}
Ok(answer) if answer.status == 401 || answer.status == 403 => {
row.error = Some("this server does not administer that peer".to_string());
}
Ok(answer) => row.error = Some(format!("answered {}", answer.status)),
Err(e) => row.error = Some(e.to_string()),
}
found.push(row);
}
found
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct PeerKeys {
pub url: String,
pub keys: Vec<String>,
pub readable: bool,
pub error: Option<String>,
}
pub async fn gossip(state: Arc<crate::handlers::SharedState>, shutdown: Arc<Shutdown>) {
let Some(galaxy) = state.galaxy.as_ref() else {
return;
};
if galaxy.peers().is_empty() || galaxy.gossip_interval == 0 {
return;
}
if state.acl_file.is_none() {
log::info!("Keys are read-only here, so none are offered to peers");
return;
}
log::info!(
"Offering this server's keys to its peers every {}s",
galaxy.gossip_interval
);
let period = Duration::from_secs(galaxy.gossip_interval);
loop {
if nap(&shutdown, period).await {
return;
}
let entries: Vec<serde_json::Value> = state
.acl()
.entries()
.iter()
.map(|(key, grants)| {
serde_json::json!(crate::admin::KeyEntry::from_grants(key, grants))
})
.collect();
let mut accepted = 0;
let mut refused = 0;
if galaxy.owns_the_acl() {
let report = galaxy
.push_key_list(&serde_json::json!(entries), galaxy.max_hops)
.await;
accepted += report.accepted;
refused += report.refused;
if report.refused > 0 {
for entry in &entries {
let per_key = galaxy.push_key(entry, galaxy.max_hops).await;
accepted += per_key.accepted;
}
}
} else {
for entry in &entries {
let report = galaxy.push_key(entry, galaxy.max_hops).await;
accepted += report.accepted;
refused += report.refused;
}
}
let tiers = state.db.tier_overrides();
for (namespace, tier, warm_idle) in &tiers {
let change = serde_json::json!({
"namespace": namespace,
"tier": tier,
"warm_idle": warm_idle,
});
let report = galaxy.push_tier(&change, galaxy.max_hops).await;
accepted += report.accepted;
refused += report.refused;
}
if accepted > 0 {
log::debug!(
"Offered {} key(s) and {} tier(s) to peers: {accepted} applied, {refused} \
not ours to administer",
entries.len(),
tiers.len()
);
}
}
}
pub async fn reconcile(state: Arc<crate::handlers::SharedState>, shutdown: Arc<Shutdown>) {
let Some(galaxy) = state.galaxy.as_ref() else {
return;
};
if galaxy.peers().is_empty() || galaxy.reconcile_interval == 0 {
return;
}
log::info!(
"Reconciling the consensus tally every {}s",
galaxy.reconcile_interval
);
let period = Duration::from_secs(galaxy.reconcile_interval);
loop {
if nap(&shutdown, period).await {
return;
}
match galaxy.rebuild_consensus(&state).await {
Ok(report) => {
if report.corrected > 0 {
log::info!(
"Consensus tally rebuilt: {} value(s) across {} namespace(s), \
{} corrected",
report.values,
report.namespaces,
report.corrected
);
} else {
log::debug!(
"Consensus tally rebuilt: {} value(s) across {} namespace(s), \
nothing to correct",
report.values,
report.namespaces
);
}
}
Err(e) => log::warn!("Could not rebuild the consensus tally: {e}"),
}
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct Reconciled {
pub namespaces: usize,
pub values: usize,
pub corrected: usize,
}
impl Galaxy {
async fn rebuild_consensus(
&self,
state: &crate::handlers::SharedState,
) -> Result<Reconciled, ForwardError> {
use std::collections::{BTreeSet, HashMap};
let mut holders: HashMap<String, BTreeSet<String>> = HashMap::new();
let mut report = Reconciled::default();
let mut seen_namespaces: BTreeSet<String> = BTreeSet::new();
for peer in &self.peers() {
let names = self.namespaces_of(peer).await?;
for namespace in names {
if !crate::db::counts_towards_consensus(&namespace) {
continue;
}
if !seen_namespaces.insert(namespace.clone()) {
continue;
}
let mut offset = 0usize;
loop {
let path = format!("/r/{namespace}?for_merge&offset={offset}&limit=500");
let answer = self
.send(peer, awc::http::Method::GET, &path, None, 1, "")
.await?;
if !(200..300).contains(&answer.status) {
break;
}
let page: MergeOfferPage = serde_json::from_slice(&answer.body)
.map_err(|e| ForwardError::Unreachable(e.to_string()))?;
let taken = page.items.len();
for offer in &page.items {
holders
.entry(offer.value.clone())
.or_default()
.insert(namespace.clone());
}
offset += taken;
if taken == 0 || offset >= page.total {
break;
}
}
}
}
report.namespaces = seen_namespaces.len();
report.values = holders.len();
report.corrected = state.db.set_consensus(&holders);
Ok(report)
}
}
async fn nap(shutdown: &Shutdown, period: Duration) -> bool {
const SLICE: Duration = Duration::from_secs(2);
let mut left = period;
while !left.is_zero() {
if shutdown.is_stopped() {
return true;
}
let slice = left.min(SLICE);
actix_web::rt::time::sleep(slice).await;
left -= slice;
}
shutdown.is_stopped()
}
#[derive(Debug, Default, Clone, Copy)]
pub struct CaughtUp {
pub namespaces: usize,
pub merged: usize,
}
impl Galaxy {
async fn catch_up(&self, state: &crate::handlers::SharedState, full: bool) -> CaughtUp {
let mut report = CaughtUp::default();
for peer in &self.peers() {
let names = match self.namespaces_of(peer).await {
Ok(names) => names,
Err(e) => {
log::debug!("Catch-up could not list {}: {e}", peer.url);
continue;
}
};
for namespace in names {
if !state.db.holds(&namespace) || crate::db::is_internal(&namespace) {
continue;
}
report.namespaces += 1;
if !full && !self.behind_on(peer, &namespace, state).await {
continue;
}
report.merged += self.pull_namespace(peer, &namespace, state).await;
}
}
report
}
async fn namespaces_of(&self, peer: &Peer) -> Result<Vec<String>, ForwardError> {
let answer = self
.send(
peer,
awc::http::Method::GET,
"/_api/namespaces",
None,
1,
"",
)
.await?;
if !(200..300).contains(&answer.status) {
return Err(ForwardError::Unreachable(format!(
"listing namespaces answered {}",
answer.status
)));
}
let parsed: serde_json::Value = serde_json::from_slice(&answer.body)
.map_err(|e| ForwardError::Unreachable(e.to_string()))?;
Ok(parsed
.get("namespaces")
.and_then(|list| list.as_array())
.map(|list| {
list.iter()
.filter_map(|name| name.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default())
}
async fn behind_on(
&self,
peer: &Peer,
namespace: &str,
state: &crate::handlers::SharedState,
) -> bool {
let path = format!("/r/{namespace}?count");
let Ok(answer) = self
.send(peer, awc::http::Method::GET, &path, None, 1, "")
.await
else {
return false;
};
let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(&answer.body) else {
return false;
};
let theirs = parsed.get("values").and_then(|v| v.as_u64()).unwrap_or(0);
let mine = state
.db
.value_count(namespace)
.map_or(0, |count| count.values as u64);
theirs > mine
}
async fn pull_namespace(
&self,
peer: &Peer,
namespace: &str,
state: &crate::handlers::SharedState,
) -> usize {
const PAGE: usize = 500;
let mut offset = 0usize;
let mut merged = 0usize;
loop {
let path = format!("/r/{namespace}?for_merge&offset={offset}&limit={PAGE}");
let answer = match self
.send(peer, awc::http::Method::GET, &path, None, 1, "")
.await
{
Ok(answer) => answer,
Err(e) => {
log::warn!("Catch-up of '{namespace}' from {} stopped: {e}", peer.url);
break;
}
};
if !(200..300).contains(&answer.status) {
log::warn!(
"Catch-up of '{namespace}' from {} stopped: answered {}",
peer.url,
answer.status
);
break;
}
let page = match serde_json::from_slice::<MergeOfferPage>(&answer.body) {
Ok(page) => page,
Err(e) => {
log::warn!(
"Catch-up of '{namespace}' from {} could not read the page: {e}",
peer.url
);
break;
}
};
let taken = page.items.len();
for offer in page.items {
let outcome = state.db.merge(namespace, &offer.value, &offer.state());
if outcome.changed {
merged += 1;
}
}
offset += taken;
if taken == 0 || offset >= page.total {
break;
}
}
if merged > 0 {
log::debug!("Took {merged} value(s) of '{namespace}' from {}", peer.url);
}
merged
}
}
#[derive(serde::Deserialize)]
struct MergeOfferPage {
items: Vec<MergeOfferItem>,
total: usize,
}
#[derive(serde::Deserialize)]
struct MergeOfferItem {
value: String,
counts: std::collections::BTreeMap<String, u64>,
#[serde(default)]
stats: std::collections::BTreeMap<String, std::collections::BTreeMap<i64, u64>>,
first_seen: i64,
last_seen: i64,
#[serde(default)]
tags: String,
#[serde(default)]
tags_at: i64,
#[serde(default)]
ttl: u64,
}
impl MergeOfferItem {
fn state(&self) -> crate::attribute::Merge {
crate::attribute::Merge {
counts: self.counts.clone(),
stats: self.stats.clone(),
first_seen: self.first_seen,
last_seen: self.last_seen,
tags: self.tags.clone(),
tags_at: self.tags_at,
ttl: self.ttl,
}
}
}
struct Probed {
version: Option<String>,
latency_ms: u64,
catching_up: bool,
}
async fn probe(client: &awc::Client, url: &str) -> Result<Probed, String> {
let at = Instant::now();
let mut response = client
.get(format!("{url}/health"))
.send()
.await
.map_err(|e| e.to_string())?;
let latency_ms = at.elapsed().as_millis() as u64;
if !response.status().is_success() {
return Err(format!("answered {}", response.status()));
}
let body = response.json::<serde_json::Value>().await.ok();
let version = body
.as_ref()
.and_then(|body| body.get("version")?.as_str().map(str::to_string));
let catching_up = body
.as_ref()
.and_then(|body| body.get("catching_up")?.as_bool())
.unwrap_or(false);
Ok(Probed {
version,
latency_ms,
catching_up,
})
}
fn client(verify_tls: bool) -> Option<awc::Client> {
CLIENT.with(|cell| {
cell.get_or_init(|| match build_client(verify_tls) {
Ok(client) => Some(client),
Err(e) => {
log::error!("Could not build an HTTP client for the galaxy: {e:#}");
None
}
})
.clone()
})
}
thread_local! {
static CLIENT: std::cell::OnceCell<Option<awc::Client>> =
const { std::cell::OnceCell::new() };
}
fn build_client(verify_tls: bool) -> anyhow::Result<awc::Client> {
use openssl::ssl::{SslConnector, SslMethod, SslVerifyMode};
let mut ssl = SslConnector::builder(SslMethod::tls())?;
if !verify_tls {
ssl.set_verify(SslVerifyMode::NONE);
}
Ok(awc::Client::builder()
.connector(awc::Connector::new().openssl(ssl.build()))
.timeout(PROBE_TIMEOUT)
.finish())
}
#[cfg(test)]
mod tests {
use super::*;
fn probed(version: Option<&str>, latency_ms: u64, catching_up: bool) -> Probed {
Probed {
version: version.map(str::to_string),
latency_ms,
catching_up,
}
}
fn galaxy(urls: &[&str]) -> Galaxy {
Galaxy::new(&GalaxySettings {
peers: urls
.iter()
.map(|url| Peer {
url: (*url).to_string(),
key: "k".to_string(),
stores: crate::db::StoragePolicy::everything(),
})
.collect(),
max_hops: 4,
peers_file: None,
fixed: Vec::new(),
health_interval: 30,
sync_interval: 300,
reconcile_interval: 3600,
gossip_interval: 600,
acl_authority: false,
acl_replaceable: false,
verify_tls: true,
})
}
#[test]
fn a_peer_starts_unprobed_rather_than_offline() {
let galaxy = galaxy(&["https://a:9999"]);
let health = galaxy.health();
assert_eq!(health.len(), 1);
assert!(!health[0].probed);
assert!(!health[0].online);
assert_eq!(health[0].last_seen, 0);
assert!(health[0].error.is_none());
}
#[test]
fn a_successful_probe_clears_the_failure_count() {
let galaxy = galaxy(&["https://a:9999"]);
galaxy.record("https://a:9999", Err("refused".into()));
galaxy.record("https://a:9999", Err("refused".into()));
assert_eq!(galaxy.health()[0].failures, 2);
assert!(!galaxy.health()[0].online);
galaxy.record("https://a:9999", Ok(probed(Some("0.6.1"), 12, false)));
let health = galaxy.health();
assert!(health[0].online);
assert!(health[0].probed);
assert_eq!(health[0].failures, 0);
assert_eq!(health[0].version.as_deref(), Some("0.6.1"));
assert_eq!(health[0].latency_ms, Some(12));
assert!(health[0].error.is_none());
assert!(health[0].last_seen > 0);
}
#[test]
fn last_seen_survives_a_later_failure() {
let galaxy = galaxy(&["https://a:9999"]);
galaxy.record("https://a:9999", Ok(probed(None, 5, false)));
let seen = galaxy.health()[0].last_seen;
assert!(seen > 0);
galaxy.record("https://a:9999", Err("timed out".into()));
let health = galaxy.health();
assert!(!health[0].online);
assert_eq!(health[0].last_seen, seen, "last_seen was lost");
assert_eq!(health[0].error.as_deref(), Some("timed out"));
assert!(health[0].latency_ms.is_none(), "a stale latency was kept");
}
#[test]
fn a_key_is_encoded_before_it_goes_in_a_path() {
let cases = [
("simple", "simple"),
("with/slash", "with%2Fslash"),
("with space", "with%20space"),
("keep-._~", "keep-._~"),
("../escape", "..%2Fescape"),
("q?a=b&c", "q%3Fa%3Db%26c"),
];
for (key, expected) in cases {
assert_eq!(urlencoding_of(key), expected, "{key}");
}
}
#[actix_web::test]
async fn a_key_change_out_of_hops_is_not_passed_on() {
let galaxy = galaxy(&["https://a:9999"]);
let report = galaxy
.push_key(
&serde_json::json!({"key": "k", "admin": false, "read": [], "write": []}),
0,
)
.await;
assert_eq!(report.accepted, 0);
assert_eq!(report.refused, 0);
assert_eq!(report.failed, 0, "it tried anyway");
}
#[actix_web::test]
async fn an_unreachable_peer_is_a_failure_not_a_refusal() {
let galaxy = galaxy(&["http://127.0.0.1:1"]);
let report = galaxy
.push_key(
&serde_json::json!({"key": "k", "admin": false, "read": [], "write": []}),
4,
)
.await;
assert_eq!(report.failed, 1);
assert_eq!(report.refused, 0);
assert_eq!(report.accepted, 0);
}
#[test]
fn reads_avoid_a_mirror_that_is_catching_up() {
let galaxy = galaxy(&["https://a:9999", "https://b:9999"]);
galaxy.record("https://a:9999", Ok(probed(None, 1, false)));
galaxy.record("https://b:9999", Ok(probed(None, 1, true)));
for value in ["1.2.3.4", "8.8.8.8", "evil.example", "a.b.c", "x"] {
let chosen = galaxy.reader_for("feeds", value).expect("a mirror");
assert_eq!(
chosen.url, "https://a:9999",
"{value} was read from a mirror that is catching up"
);
}
}
#[test]
fn reads_are_served_even_when_every_mirror_is_catching_up() {
let galaxy = galaxy(&["https://a:9999", "https://b:9999"]);
galaxy.record("https://a:9999", Ok(probed(None, 1, true)));
galaxy.record("https://b:9999", Ok(probed(None, 1, true)));
assert!(galaxy.reader_for("feeds", "1.2.3.4").is_some());
}
#[test]
fn catching_up_is_tracked_and_cleared() {
let galaxy = galaxy(&["https://a:9999"]);
galaxy.record("https://a:9999", Ok(probed(None, 1, true)));
assert!(galaxy.health()[0].catching_up);
galaxy.record("https://a:9999", Ok(probed(None, 1, false)));
assert!(!galaxy.health()[0].catching_up);
}
#[test]
fn a_value_always_reads_from_the_same_mirror() {
let galaxy = galaxy(&["https://a:9999", "https://b:9999", "https://c:9999"]);
for url in ["https://a:9999", "https://b:9999", "https://c:9999"] {
galaxy.record(url, Ok(probed(None, 1, false)));
}
for value in ["1.2.3.4", "evil.example", "deadbeef"] {
let first = galaxy.reader_for("feeds", value).unwrap().url.clone();
for _ in 0..5 {
assert_eq!(
galaxy.reader_for("feeds", value).unwrap().url,
first,
"{value} moved between reads"
);
}
}
}
#[test]
fn health_is_reported_in_configured_order() {
let galaxy = galaxy(&["https://c:9999", "https://a:9999", "https://b:9999"]);
galaxy.record("https://a:9999", Ok(probed(None, 1, false)));
let health = galaxy.health();
let urls: Vec<&str> = health.iter().map(|h| h.url.as_str()).collect();
assert_eq!(urls, ["https://c:9999", "https://a:9999", "https://b:9999"]);
}
}