Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ async-stream = "0.3"
tokio-stream = "0.1"
uuid.workspace = true
hex.workspace = true
reqwest = { version = "0.12", features = ["json"] }

[dev-dependencies]
tower = "0.5"
Expand Down
20 changes: 19 additions & 1 deletion src/bin/gateway.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use monoce_gateway::{HttpGateway, AppState};
use monoce_gateway::{HttpGateway, AppState, cluster::{health_check_loop, replication_loop}};
use std::net::SocketAddr;
use tracing_subscriber;

Expand Down Expand Up @@ -27,6 +27,24 @@ async fn main() -> anyhow::Result<()> {
// Create app state
let state = AppState::new(node_id, cluster_id);

// Spawn health check background task
let health_state = state.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
health_check_loop(&health_state).await;
}
});

// Spawn replication reconciliation background task
let replication_state = state.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
replication_loop(&replication_state).await;
}
});

// Create and start gateway
let gateway = HttpGateway::new(addr, state);

Expand Down
132 changes: 132 additions & 0 deletions src/cluster.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
use crate::handlers::AppState;
use std::time::{SystemTime, UNIX_EPOCH};

pub async fn health_check_loop(state: &AppState) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Holding the node_registry write lock across awaits in health_check_loop can block readers/writers and cause contention. Consider snapshotting node IDs/addresses under a read lock, drop the lock, run health checks, then re-acquire a write lock to apply updates.

🚀 Want me to fix this? Reply ex: "fix it for me".

let mut registry = state.node_registry.write().await;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;

for (node_id, record) in registry.nodes.iter_mut() {
let is_healthy = check_node_health(&record.address).await;
let was_online = record.is_online;
record.is_online = is_healthy;
if is_healthy {
record.last_seen_ms = now;
}
if was_online != is_healthy {
if is_healthy {
tracing::info!("Node {} is now ONLINE", node_id);
} else {
tracing::warn!("Node {} is now OFFLINE", node_id);
}
Comment on lines +3 to +23

Copilot AI Jan 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The health_check_loop holds a write lock on the entire node registry while performing potentially slow health checks for all nodes sequentially. This blocks other operations that need to read or write the registry. Consider collecting addresses first, releasing the lock, performing health checks concurrently, then re-acquiring the lock only to update the results.

Suggested change
pub async fn health_check_loop(state: &AppState) {
let mut registry = state.node_registry.write().await;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
for (node_id, record) in registry.nodes.iter_mut() {
let is_healthy = check_node_health(&record.address).await;
let was_online = record.is_online;
record.is_online = is_healthy;
if is_healthy {
record.last_seen_ms = now;
}
if was_online != is_healthy {
if is_healthy {
tracing::info!("Node {} is now ONLINE", node_id);
} else {
tracing::warn!("Node {} is now OFFLINE", node_id);
}
use tokio::task::JoinSet;
pub async fn health_check_loop(state: &AppState) {
// Collect node IDs and their addresses under a short-lived read lock
let nodes: Vec<(String, String)> = {
let registry = state.node_registry.read().await;
registry
.nodes
.iter()
.map(|(node_id, record)| (node_id.clone(), record.address.clone()))
.collect()
};
// Perform health checks concurrently without holding the registry lock
let mut tasks: JoinSet<(String, bool)> = JoinSet::new();
for (node_id, address) in nodes.into_iter() {
tasks.spawn(async move {
let is_healthy = check_node_health(&address).await;
(node_id, is_healthy)
});
}
let mut results: Vec<(String, bool)> = Vec::new();
while let Some(join_result) = tasks.join_next().await {
if let Ok((node_id, is_healthy)) = join_result {
results.push((node_id, is_healthy));
}
}
// Update registry state with health check results under a write lock
let mut registry = state.node_registry.write().await;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
for (node_id, is_healthy) in results.into_iter() {
if let Some(record) = registry.nodes.get_mut(&node_id) {
let was_online = record.is_online;
record.is_online = is_healthy;
if is_healthy {
record.last_seen_ms = now;
}
if was_online != is_healthy {
if is_healthy {
tracing::info!("Node {} is now ONLINE", node_id);
} else {
tracing::warn!("Node {} is now OFFLINE", node_id);
}
}

Copilot uses AI. Check for mistakes.
}
}
}

async fn check_node_health(address: &str) -> bool {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(2))
.build()
.ok();
if let Some(client) = client {
client.get(format!("{}/health", address))
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false)
} else {
false
}
}

pub async fn replication_loop(state: &AppState) {
// Get current chunk locations and node states
let registry = state.node_registry.read().await;
let locations = state.chunk_locations.read().await;

let online_nodes: Vec<String> = registry.nodes.values()
.filter(|n| n.is_online)
.map(|n| n.address.clone())
.collect();

if online_nodes.is_empty() {
return;
}

// Collect chunks that need replication
let mut replication_tasks: Vec<(String, String, String)> = Vec::new();

for (chunk_cid, node_addrs) in locations.iter() {
let online_replicas: Vec<&String> = node_addrs.iter()
.filter(|addr| online_nodes.contains(addr))
.collect();
Comment on lines +62 to +64

Copilot AI Jan 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The online_nodes.contains(addr) check on line 63 performs a linear search for each address. Since this is inside a loop over all chunks and their addresses, this creates O(n*m) complexity. Convert online_nodes to a HashSet for O(1) lookups.

Copilot uses AI. Check for mistakes.

// If under-replicated (less than 2 replicas online), try to replicate
if online_replicas.len() < 2 && !online_replicas.is_empty() {

Copilot AI Jan 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The replication target of 2 replicas is hardcoded in the replication loop. This should be configurable (e.g., from the original replication_factor used during chunk allocation) to support different replication requirements across different chunks or use cases.

Copilot uses AI. Check for mistakes.
// Find a node that doesn't have this chunk
let missing_nodes: Vec<&String> = online_nodes.iter()
.filter(|n| !node_addrs.contains(n))
.collect();

if let Some(dest_node) = missing_nodes.first() {
if let Some(src_node) = online_replicas.first() {
replication_tasks.push((
chunk_cid.clone(),
(*src_node).clone(),
(*dest_node).clone(),
));
}
}
}
}

// Release read locks before performing replications
drop(locations);
drop(registry);

// Perform replications and update locations
for (chunk_cid, src_node, dest_node) in replication_tasks {
tracing::info!(
"Replicating chunk {} from {} to {}",
chunk_cid, src_node, dest_node
);

match replicate_chunk(&chunk_cid, &src_node, &dest_node).await {
Ok(_) => {
// Update chunk_locations after successful replication
let mut locations = state.chunk_locations.write().await;
if let Some(addrs) = locations.get_mut(&chunk_cid) {
if !addrs.contains(&dest_node) {
addrs.push(dest_node.clone());
}
}
tracing::info!("Successfully replicated chunk {} to {}", chunk_cid, dest_node);
}
Err(e) => {
tracing::error!("Failed to replicate chunk {}: {}", chunk_cid, e);
}
}
}
}

async fn replicate_chunk(cid: &str, src: &str, dest: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = reqwest::Client::new();

// Fetch from source
let data = client.get(format!("{}/api/v1/chunks/{}", src, cid))
.send()
.await?
.bytes()
.await?;
Comment on lines +118 to +122

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replicate_chunk doesn’t validate the source response; an error (e.g., 404/500) could be read and uploaded. Consider calling .error_for_status()? on the source response before .bytes().

Suggested change
let data = client.get(format!("{}/api/v1/chunks/{}", src, cid))
.send()
.await?
.bytes()
.await?;
let data = client.get(format!("{}/api/v1/chunks/{}", src, cid))
.send()
.await?
.error_for_status()?
.bytes()
.await?;

🚀 Want me to fix this? Reply ex: "fix it for me".


// Upload to destination
client.put(format!("{}/api/v1/chunks/{}", dest, cid))
.body(data)
.send()
.await?
.error_for_status()?;
Comment on lines +114 to +129

Copilot AI Jan 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The replicate_chunk function creates a new HTTP client for each replication. Creating a client has overhead (connection pools, DNS resolution, etc.). Consider reusing a shared client instance across replication operations for better performance.

Copilot uses AI. Check for mistakes.

Ok(())
}
Loading