fix cluster resiliency gaps across VM watch, runtime health, and FlareDB routing
This commit is contained in:
parent
1698009062
commit
9dfe86f92a
10 changed files with 1229 additions and 464 deletions
|
|
@ -3,24 +3,13 @@
|
|||
use crate::error::{ClientError, Result};
|
||||
use crate::watch::WatchHandle;
|
||||
use chainfire_proto::proto::{
|
||||
cluster_client::ClusterClient,
|
||||
compare,
|
||||
kv_client::KvClient,
|
||||
request_op,
|
||||
response_op,
|
||||
watch_client::WatchClient,
|
||||
Compare,
|
||||
DeleteRangeRequest,
|
||||
MemberAddRequest,
|
||||
PutRequest,
|
||||
RangeRequest,
|
||||
RequestOp,
|
||||
StatusRequest,
|
||||
TxnRequest,
|
||||
cluster_client::ClusterClient, compare, kv_client::KvClient, request_op, response_op,
|
||||
watch_client::WatchClient, Compare, DeleteRangeRequest, MemberAddRequest, PutRequest,
|
||||
RangeRequest, RequestOp, StatusRequest, TxnRequest,
|
||||
};
|
||||
use std::time::Duration;
|
||||
use tonic::Code;
|
||||
use tonic::transport::Channel;
|
||||
use tonic::Code;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Chainfire client
|
||||
|
|
@ -64,7 +53,9 @@ impl Client {
|
|||
}
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| ClientError::Connection("no Chainfire endpoints configured".to_string())))
|
||||
Err(last_error.unwrap_or_else(|| {
|
||||
ClientError::Connection("no Chainfire endpoints configured".to_string())
|
||||
}))
|
||||
}
|
||||
|
||||
async fn with_kv_retry<T, F, Fut>(&mut self, mut op: F) -> Result<T>
|
||||
|
|
@ -88,14 +79,17 @@ impl Client {
|
|||
"retrying Chainfire KV RPC on alternate endpoint"
|
||||
);
|
||||
last_status = Some(status);
|
||||
self.recover_after_status(last_status.as_ref().unwrap()).await?;
|
||||
self.recover_after_status(last_status.as_ref().unwrap())
|
||||
.await?;
|
||||
tokio::time::sleep(retry_delay(attempt)).await;
|
||||
}
|
||||
Err(status) => return Err(status.into()),
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_status.unwrap_or_else(|| tonic::Status::unavailable("Chainfire KV retry exhausted")).into())
|
||||
Err(last_status
|
||||
.unwrap_or_else(|| tonic::Status::unavailable("Chainfire KV retry exhausted"))
|
||||
.into())
|
||||
}
|
||||
|
||||
async fn with_cluster_retry<T, F, Fut>(&mut self, mut op: F) -> Result<T>
|
||||
|
|
@ -119,14 +113,17 @@ impl Client {
|
|||
"retrying Chainfire cluster RPC on alternate endpoint"
|
||||
);
|
||||
last_status = Some(status);
|
||||
self.recover_after_status(last_status.as_ref().unwrap()).await?;
|
||||
self.recover_after_status(last_status.as_ref().unwrap())
|
||||
.await?;
|
||||
tokio::time::sleep(retry_delay(attempt)).await;
|
||||
}
|
||||
Err(status) => return Err(status.into()),
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_status.unwrap_or_else(|| tonic::Status::unavailable("Chainfire cluster retry exhausted")).into())
|
||||
Err(last_status
|
||||
.unwrap_or_else(|| tonic::Status::unavailable("Chainfire cluster retry exhausted"))
|
||||
.into())
|
||||
}
|
||||
|
||||
async fn recover_after_status(&mut self, status: &tonic::Status) -> Result<()> {
|
||||
|
|
@ -150,7 +147,9 @@ impl Client {
|
|||
let endpoint = self
|
||||
.endpoints
|
||||
.get(index)
|
||||
.ok_or_else(|| ClientError::Connection(format!("invalid Chainfire endpoint index {index}")))?
|
||||
.ok_or_else(|| {
|
||||
ClientError::Connection(format!("invalid Chainfire endpoint index {index}"))
|
||||
})?
|
||||
.clone();
|
||||
let (channel, kv, cluster) = connect_endpoint(&endpoint).await?;
|
||||
self.current_endpoint = index;
|
||||
|
|
@ -182,7 +181,11 @@ impl Client {
|
|||
match cluster.status(StatusRequest {}).await {
|
||||
Ok(response) => {
|
||||
let status = response.into_inner();
|
||||
let member_id = status.header.as_ref().map(|header| header.member_id).unwrap_or(0);
|
||||
let member_id = status
|
||||
.header
|
||||
.as_ref()
|
||||
.map(|header| header.member_id)
|
||||
.unwrap_or(0);
|
||||
if status.leader != 0 && status.leader == member_id {
|
||||
return Ok(Some(index));
|
||||
}
|
||||
|
|
@ -232,10 +235,7 @@ impl Client {
|
|||
|
||||
/// Get a value by key
|
||||
pub async fn get(&mut self, key: impl AsRef<[u8]>) -> Result<Option<Vec<u8>>> {
|
||||
Ok(self
|
||||
.get_with_revision(key)
|
||||
.await?
|
||||
.map(|(value, _)| value))
|
||||
Ok(self.get_with_revision(key).await?.map(|(value, _)| value))
|
||||
}
|
||||
|
||||
/// Get a value by key along with its current revision
|
||||
|
|
@ -263,13 +263,14 @@ impl Client {
|
|||
})
|
||||
.await?;
|
||||
|
||||
Ok(resp.kvs.into_iter().next().map(|kv| (kv.value, kv.mod_revision as u64)))
|
||||
Ok(resp
|
||||
.kvs
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|kv| (kv.value, kv.mod_revision as u64)))
|
||||
}
|
||||
|
||||
/// Put a key-value pair only if the key's mod_revision matches.
|
||||
///
|
||||
/// This is a best-effort compare-and-set. The server may not return
|
||||
/// a reliable success flag, so callers should treat this as "attempted".
|
||||
pub async fn put_if_revision(
|
||||
&mut self,
|
||||
key: impl AsRef<[u8]>,
|
||||
|
|
@ -277,6 +278,7 @@ impl Client {
|
|||
expected_mod_revision: u64,
|
||||
) -> Result<()> {
|
||||
let key_bytes = key.as_ref().to_vec();
|
||||
let key_display = String::from_utf8_lossy(&key_bytes).to_string();
|
||||
let compare = Compare {
|
||||
result: compare::CompareResult::Equal as i32,
|
||||
target: compare::CompareTarget::Mod as i32,
|
||||
|
|
@ -288,21 +290,35 @@ impl Client {
|
|||
|
||||
let put_op = RequestOp {
|
||||
request: Some(request_op::Request::RequestPut(PutRequest {
|
||||
key: key_bytes,
|
||||
key: key_bytes.clone(),
|
||||
value: value.as_ref().to_vec(),
|
||||
lease: 0,
|
||||
prev_kv: false,
|
||||
})),
|
||||
};
|
||||
|
||||
self.with_kv_retry(|mut kv| {
|
||||
let read_on_fail = RequestOp {
|
||||
request: Some(request_op::Request::RequestRange(RangeRequest {
|
||||
key: key_bytes.clone(),
|
||||
range_end: vec![],
|
||||
limit: 1,
|
||||
revision: 0,
|
||||
keys_only: false,
|
||||
count_only: false,
|
||||
serializable: false,
|
||||
})),
|
||||
};
|
||||
|
||||
let resp = self
|
||||
.with_kv_retry(|mut kv| {
|
||||
let compare = compare.clone();
|
||||
let put_op = put_op.clone();
|
||||
let read_on_fail = read_on_fail.clone();
|
||||
async move {
|
||||
kv.txn(TxnRequest {
|
||||
compare: vec![compare],
|
||||
success: vec![put_op],
|
||||
failure: vec![],
|
||||
failure: vec![read_on_fail],
|
||||
})
|
||||
.await
|
||||
.map(|resp| resp.into_inner())
|
||||
|
|
@ -310,7 +326,27 @@ impl Client {
|
|||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
if resp.succeeded {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let current_revision = resp
|
||||
.responses
|
||||
.into_iter()
|
||||
.filter_map(|op| match op.response {
|
||||
Some(response_op::Response::ResponseRange(range)) => range
|
||||
.kvs
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|kv| kv.mod_revision as u64),
|
||||
_ => None,
|
||||
})
|
||||
.next()
|
||||
.unwrap_or(0);
|
||||
|
||||
Err(ClientError::Conflict(format!(
|
||||
"mod_revision mismatch for key {key_display}: expected {expected_mod_revision}, current {current_revision}"
|
||||
)))
|
||||
}
|
||||
|
||||
/// Get a value as string
|
||||
|
|
@ -341,7 +377,10 @@ impl Client {
|
|||
}
|
||||
|
||||
/// Get all keys with a prefix
|
||||
pub async fn get_prefix(&mut self, prefix: impl AsRef<[u8]>) -> Result<Vec<(Vec<u8>, Vec<u8>)>> {
|
||||
pub async fn get_prefix(
|
||||
&mut self,
|
||||
prefix: impl AsRef<[u8]>,
|
||||
) -> Result<Vec<(Vec<u8>, Vec<u8>)>> {
|
||||
let prefix = prefix.as_ref();
|
||||
let range_end = prefix_end(prefix);
|
||||
|
||||
|
|
@ -404,8 +443,7 @@ impl Client {
|
|||
.map(|kv| (kv.key, kv.value, kv.mod_revision as u64))
|
||||
.collect();
|
||||
let next_key = if more {
|
||||
kvs.last()
|
||||
.map(|(k, _, _)| {
|
||||
kvs.last().map(|(k, _, _)| {
|
||||
let mut nk = k.clone();
|
||||
nk.push(0);
|
||||
nk
|
||||
|
|
@ -451,8 +489,7 @@ impl Client {
|
|||
.map(|kv| (kv.key, kv.value, kv.mod_revision as u64))
|
||||
.collect();
|
||||
let next_key = if more {
|
||||
kvs.last()
|
||||
.map(|(k, _, _)| {
|
||||
kvs.last().map(|(k, _, _)| {
|
||||
let mut nk = k.clone();
|
||||
nk.push(0);
|
||||
nk
|
||||
|
|
@ -519,11 +556,7 @@ impl Client {
|
|||
.await?;
|
||||
|
||||
if resp.succeeded {
|
||||
let new_version = resp
|
||||
.header
|
||||
.as_ref()
|
||||
.map(|h| h.revision as u64)
|
||||
.unwrap_or(0);
|
||||
let new_version = resp.header.as_ref().map(|h| h.revision as u64).unwrap_or(0);
|
||||
return Ok(CasOutcome {
|
||||
success: true,
|
||||
current_version: new_version,
|
||||
|
|
@ -536,11 +569,9 @@ impl Client {
|
|||
.responses
|
||||
.into_iter()
|
||||
.filter_map(|op| match op.response {
|
||||
Some(response_op::Response::ResponseRange(r)) => r
|
||||
.kvs
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|kv| kv.mod_revision as u64),
|
||||
Some(response_op::Response::ResponseRange(r)) => {
|
||||
r.kvs.into_iter().next().map(|kv| kv.mod_revision as u64)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.next()
|
||||
|
|
@ -594,7 +625,12 @@ impl Client {
|
|||
///
|
||||
/// # Returns
|
||||
/// The node ID of the added member
|
||||
pub async fn member_add(&mut self, node_id: u64, peer_url: impl AsRef<str>, is_learner: bool) -> Result<u64> {
|
||||
pub async fn member_add(
|
||||
&mut self,
|
||||
node_id: u64,
|
||||
peer_url: impl AsRef<str>,
|
||||
is_learner: bool,
|
||||
) -> Result<u64> {
|
||||
let peer_url = peer_url.as_ref().to_string();
|
||||
let resp = self
|
||||
.with_cluster_retry(|mut cluster| {
|
||||
|
|
@ -660,7 +696,9 @@ fn parse_endpoints(input: &str) -> Result<Vec<String>> {
|
|||
.collect();
|
||||
|
||||
if endpoints.is_empty() {
|
||||
return Err(ClientError::Connection("no Chainfire endpoints configured".to_string()));
|
||||
return Err(ClientError::Connection(
|
||||
"no Chainfire endpoints configured".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(endpoints)
|
||||
|
|
@ -674,7 +712,9 @@ fn normalize_endpoint(endpoint: &str) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
async fn connect_endpoint(endpoint: &str) -> Result<(Channel, KvClient<Channel>, ClusterClient<Channel>)> {
|
||||
async fn connect_endpoint(
|
||||
endpoint: &str,
|
||||
) -> Result<(Channel, KvClient<Channel>, ClusterClient<Channel>)> {
|
||||
let channel = Channel::from_shared(endpoint.to_string())
|
||||
.map_err(|e| ClientError::Connection(e.to_string()))?
|
||||
.connect()
|
||||
|
|
@ -693,7 +733,11 @@ fn retry_delay(attempt: usize) -> Duration {
|
|||
fn is_retryable_status(status: &tonic::Status) -> bool {
|
||||
matches!(
|
||||
status.code(),
|
||||
Code::Unavailable | Code::DeadlineExceeded | Code::Internal | Code::Aborted | Code::FailedPrecondition
|
||||
Code::Unavailable
|
||||
| Code::DeadlineExceeded
|
||||
| Code::Internal
|
||||
| Code::Aborted
|
||||
| Code::FailedPrecondition
|
||||
) || retryable_message(status.message())
|
||||
}
|
||||
|
||||
|
|
@ -734,8 +778,14 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn normalize_endpoint_adds_http_scheme() {
|
||||
assert_eq!(normalize_endpoint("127.0.0.1:2379"), "http://127.0.0.1:2379");
|
||||
assert_eq!(normalize_endpoint("http://127.0.0.1:2379"), "http://127.0.0.1:2379");
|
||||
assert_eq!(
|
||||
normalize_endpoint("127.0.0.1:2379"),
|
||||
"http://127.0.0.1:2379"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_endpoint("http://127.0.0.1:2379"),
|
||||
"http://127.0.0.1:2379"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -31,4 +31,8 @@ pub enum ClientError {
|
|||
/// Internal error
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
|
||||
/// Compare-and-set conflict
|
||||
#[error("Conflict: {0}")]
|
||||
Conflict(String),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use std::process::Stdio;
|
|||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chainfire_client::Client;
|
||||
use chainfire_client::{Client, ClientError};
|
||||
use chrono::{DateTime, Utc};
|
||||
use deployer_types::{ContainerSpec, HealthCheckSpec, ProcessSpec, ServiceInstanceSpec};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -254,7 +254,11 @@ impl Agent {
|
|||
}
|
||||
}
|
||||
|
||||
fn render_container_spec(&self, spec: &ContainerSpec, inst: &ServiceInstanceSpec) -> ContainerSpec {
|
||||
fn render_container_spec(
|
||||
&self,
|
||||
spec: &ContainerSpec,
|
||||
inst: &ServiceInstanceSpec,
|
||||
) -> ContainerSpec {
|
||||
let mut rendered = spec.clone();
|
||||
rendered.image = self.render_template_value(&rendered.image, inst);
|
||||
rendered.command = rendered
|
||||
|
|
@ -283,10 +287,7 @@ impl Agent {
|
|||
rendered
|
||||
}
|
||||
|
||||
fn desired_process_spec(
|
||||
&self,
|
||||
inst: &ServiceInstanceSpec,
|
||||
) -> Option<ProcessSpec> {
|
||||
fn desired_process_spec(&self, inst: &ServiceInstanceSpec) -> Option<ProcessSpec> {
|
||||
match (&inst.container, &inst.process) {
|
||||
(Some(container), maybe_process) => {
|
||||
if maybe_process.is_some() {
|
||||
|
|
@ -309,6 +310,93 @@ impl Agent {
|
|||
}
|
||||
}
|
||||
|
||||
fn apply_instance_health_fields(
|
||||
inst_value: &mut Value,
|
||||
started_at: &DateTime<Utc>,
|
||||
heartbeat_at: &DateTime<Utc>,
|
||||
health_status: &str,
|
||||
) {
|
||||
let Some(obj) = inst_value.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let observed_at = Value::String(started_at.to_rfc3339());
|
||||
match obj.get_mut("observed_at") {
|
||||
Some(slot) if slot.is_null() => *slot = observed_at,
|
||||
Some(_) => {}
|
||||
None => {
|
||||
obj.insert("observed_at".to_string(), observed_at);
|
||||
}
|
||||
}
|
||||
|
||||
obj.insert(
|
||||
"state".to_string(),
|
||||
Value::String(health_status.to_string()),
|
||||
);
|
||||
obj.insert(
|
||||
"last_heartbeat".to_string(),
|
||||
Value::String(heartbeat_at.to_rfc3339()),
|
||||
);
|
||||
}
|
||||
|
||||
async fn persist_instance_health(
|
||||
&self,
|
||||
client: &mut Client,
|
||||
key: &[u8],
|
||||
inst: &ServiceInstanceSpec,
|
||||
mut inst_value: Value,
|
||||
mut mod_revision: u64,
|
||||
started_at: &DateTime<Utc>,
|
||||
health_status: &str,
|
||||
) -> Result<()> {
|
||||
for attempt in 0..3 {
|
||||
let heartbeat_at = Utc::now();
|
||||
Self::apply_instance_health_fields(
|
||||
&mut inst_value,
|
||||
started_at,
|
||||
&heartbeat_at,
|
||||
health_status,
|
||||
);
|
||||
|
||||
let updated = serde_json::to_vec(&inst_value)?;
|
||||
match client.put_if_revision(key, &updated, mod_revision).await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(ClientError::Conflict(error)) if attempt < 2 => {
|
||||
warn!(
|
||||
service = %inst.service,
|
||||
instance_id = %inst.instance_id,
|
||||
mod_revision,
|
||||
attempt = attempt + 1,
|
||||
error = %error,
|
||||
"instance health update raced with another writer; retrying with fresh revision"
|
||||
);
|
||||
|
||||
let Some((latest_bytes, latest_revision)) =
|
||||
client.get_with_revision(key).await?
|
||||
else {
|
||||
warn!(
|
||||
service = %inst.service,
|
||||
instance_id = %inst.instance_id,
|
||||
"instance disappeared while retrying health update"
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
inst_value = serde_json::from_slice(&latest_bytes).with_context(|| {
|
||||
format!(
|
||||
"failed to parse refreshed instance JSON for {}",
|
||||
inst.instance_id
|
||||
)
|
||||
})?;
|
||||
mod_revision = latest_revision;
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// ローカルファイル (/etc/photoncloud/instances.json) から ServiceInstance 定義を読み、
|
||||
/// Chainfire 上の `photoncloud/clusters/{cluster_id}/instances/{service}/{instance_id}` に upsert する。
|
||||
async fn sync_local_instances(&self, client: &mut Client) -> Result<()> {
|
||||
|
|
@ -348,7 +436,9 @@ impl Agent {
|
|||
{
|
||||
for preserve_key in ["state", "last_heartbeat", "observed_at"] {
|
||||
if let Some(value) = existing_obj.get(preserve_key) {
|
||||
desired_obj.entry(preserve_key.to_string()).or_insert(value.clone());
|
||||
desired_obj
|
||||
.entry(preserve_key.to_string())
|
||||
.or_insert(value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -398,7 +488,6 @@ impl Agent {
|
|||
|
||||
// Desired Stateに基づいてプロセスを管理
|
||||
for (service, instance_id, proc_spec) in desired_instances {
|
||||
|
||||
if let Some(process) = self.process_manager.get_mut(&service, &instance_id) {
|
||||
if process.spec != proc_spec {
|
||||
process.spec = proc_spec;
|
||||
|
|
@ -526,7 +615,7 @@ impl Agent {
|
|||
let mut seen = HashSet::new();
|
||||
|
||||
for (key, value, mod_revision) in kvs {
|
||||
let mut inst_value: Value = match serde_json::from_slice(&value) {
|
||||
let inst_value: Value = match serde_json::from_slice(&value) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "failed to parse instance json");
|
||||
|
|
@ -582,22 +671,18 @@ impl Agent {
|
|||
"healthy".to_string() // デフォルトはhealthy
|
||||
};
|
||||
|
||||
// Chainfire上のServiceInstanceに状態を反映
|
||||
if let Some(obj) = inst_value.as_object_mut() {
|
||||
obj.entry("observed_at".to_string())
|
||||
.or_insert_with(|| Value::String(started_at.to_rfc3339()));
|
||||
obj.insert(
|
||||
"state".to_string(),
|
||||
Value::String(health_status.clone()),
|
||||
);
|
||||
obj.insert(
|
||||
"last_heartbeat".to_string(),
|
||||
Value::String(now.to_rfc3339()),
|
||||
);
|
||||
}
|
||||
|
||||
let updated = serde_json::to_vec(&inst_value)?;
|
||||
if let Err(e) = client.put_if_revision(&key, &updated, mod_revision).await {
|
||||
if let Err(e) = self
|
||||
.persist_instance_health(
|
||||
client,
|
||||
&key,
|
||||
&inst,
|
||||
inst_value,
|
||||
mod_revision,
|
||||
&started_at,
|
||||
&health_status,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
service = %inst.service,
|
||||
instance_id = %inst.instance_id,
|
||||
|
|
@ -615,8 +700,7 @@ impl Agent {
|
|||
);
|
||||
}
|
||||
|
||||
self.next_health_checks
|
||||
.retain(|key, _| seen.contains(key));
|
||||
self.next_health_checks.retain(|key, _| seen.contains(key));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -732,7 +816,10 @@ mod tests {
|
|||
assert_eq!(rendered.args[2], "18080");
|
||||
assert_eq!(rendered.args[4], "127.0.0.2");
|
||||
assert_eq!(rendered.working_dir.as_deref(), Some("/srv/api"));
|
||||
assert_eq!(rendered.env.get("INSTANCE").map(String::as_str), Some("api-node01"));
|
||||
assert_eq!(
|
||||
rendered.env.get("INSTANCE").map(String::as_str),
|
||||
Some("api-node01")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -779,4 +866,59 @@ mod tests {
|
|||
.insert(key, Utc::now() - chrono::Duration::seconds(1));
|
||||
assert!(agent.health_check_due(&instance, &health_check));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_instance_health_fields_replaces_nulls_and_preserves_observed_at() {
|
||||
let started_at = DateTime::parse_from_rfc3339("2026-03-31T03:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc);
|
||||
let heartbeat_at = DateTime::parse_from_rfc3339("2026-03-31T03:00:05Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc);
|
||||
let original_observed_at = "2026-03-31T02:59:59Z";
|
||||
|
||||
let mut null_observed = serde_json::json!({
|
||||
"observed_at": null,
|
||||
"state": null,
|
||||
"last_heartbeat": null
|
||||
});
|
||||
Agent::apply_instance_health_fields(
|
||||
&mut null_observed,
|
||||
&started_at,
|
||||
&heartbeat_at,
|
||||
"healthy",
|
||||
);
|
||||
assert_eq!(
|
||||
null_observed.get("observed_at").and_then(Value::as_str),
|
||||
Some("2026-03-31T03:00:00+00:00")
|
||||
);
|
||||
assert_eq!(
|
||||
null_observed.get("state").and_then(Value::as_str),
|
||||
Some("healthy")
|
||||
);
|
||||
assert_eq!(
|
||||
null_observed.get("last_heartbeat").and_then(Value::as_str),
|
||||
Some("2026-03-31T03:00:05+00:00")
|
||||
);
|
||||
|
||||
let mut existing_observed = serde_json::json!({
|
||||
"observed_at": original_observed_at,
|
||||
"state": "starting",
|
||||
"last_heartbeat": null
|
||||
});
|
||||
Agent::apply_instance_health_fields(
|
||||
&mut existing_observed,
|
||||
&started_at,
|
||||
&heartbeat_at,
|
||||
"healthy",
|
||||
);
|
||||
assert_eq!(
|
||||
existing_observed.get("observed_at").and_then(Value::as_str),
|
||||
Some(original_observed_at)
|
||||
);
|
||||
assert_eq!(
|
||||
existing_observed.get("state").and_then(Value::as_str),
|
||||
Some("healthy")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ use flaredb_proto::kvrpc::{
|
|||
RawScanRequest,
|
||||
};
|
||||
use flaredb_proto::pdpb::Store;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::Mutex;
|
||||
use tonic::transport::Channel;
|
||||
|
||||
|
|
@ -61,6 +61,74 @@ struct ChainfireRouteSnapshot {
|
|||
fetched_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ResolvedRoute {
|
||||
region: Region,
|
||||
leader: Store,
|
||||
candidate_addrs: Vec<String>,
|
||||
}
|
||||
|
||||
fn push_unique_addr(addrs: &mut Vec<String>, addr: &str) {
|
||||
if !addrs.iter().any(|existing| existing == addr) {
|
||||
addrs.push(addr.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_chainfire_route_from_snapshot(
|
||||
key: &[u8],
|
||||
snapshot: &ChainfireRouteSnapshot,
|
||||
) -> Result<ResolvedRoute, tonic::Status> {
|
||||
let region = snapshot
|
||||
.regions
|
||||
.iter()
|
||||
.find(|region| {
|
||||
let start_ok = region.start_key.is_empty() || key >= region.start_key.as_slice();
|
||||
let end_ok = region.end_key.is_empty() || key < region.end_key.as_slice();
|
||||
start_ok && end_ok
|
||||
})
|
||||
.cloned()
|
||||
.ok_or_else(|| tonic::Status::not_found("region not found"))?;
|
||||
|
||||
let mut candidate_addrs = Vec::new();
|
||||
let mut selected_store = snapshot.stores.get(®ion.leader_id).cloned();
|
||||
|
||||
if let Some(store) = &selected_store {
|
||||
push_unique_addr(&mut candidate_addrs, &store.addr);
|
||||
}
|
||||
|
||||
for peer_id in ®ion.peers {
|
||||
if let Some(store) = snapshot.stores.get(peer_id) {
|
||||
if selected_store.is_none() {
|
||||
selected_store = Some(store.clone());
|
||||
}
|
||||
push_unique_addr(&mut candidate_addrs, &store.addr);
|
||||
}
|
||||
}
|
||||
|
||||
let selected_store = selected_store
|
||||
.ok_or_else(|| tonic::Status::not_found("region peer store not found"))?;
|
||||
if candidate_addrs.is_empty() {
|
||||
return Err(tonic::Status::not_found(
|
||||
"region has no candidate store addresses",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(ResolvedRoute {
|
||||
region: Region {
|
||||
id: region.id,
|
||||
start_key: region.start_key,
|
||||
end_key: region.end_key,
|
||||
peers: region.peers,
|
||||
leader_id: region.leader_id,
|
||||
},
|
||||
leader: Store {
|
||||
id: selected_store.id,
|
||||
addr: selected_store.addr,
|
||||
},
|
||||
candidate_addrs,
|
||||
})
|
||||
}
|
||||
|
||||
impl RdbClient {
|
||||
const ROUTE_RETRY_LIMIT: usize = 12;
|
||||
const ROUTE_RETRY_BASE_DELAY_MS: u64 = 100;
|
||||
|
|
@ -116,11 +184,9 @@ impl RdbClient {
|
|||
.await;
|
||||
|
||||
clients = Some(match probe {
|
||||
Err(status) if status.code() == tonic::Code::Unimplemented => (
|
||||
None,
|
||||
None,
|
||||
Some(ChainfireKvClient::new(pd_channel)),
|
||||
),
|
||||
Err(status) if status.code() == tonic::Code::Unimplemented => {
|
||||
(None, None, Some(ChainfireKvClient::new(pd_channel)))
|
||||
}
|
||||
_ => (
|
||||
Some(TsoClient::new(pd_channel.clone())),
|
||||
Some(PdClient::new(pd_channel)),
|
||||
|
|
@ -134,13 +200,11 @@ impl RdbClient {
|
|||
} else if let Some(error) = last_error {
|
||||
return Err(error);
|
||||
} else {
|
||||
return Err(
|
||||
Channel::from_shared("http://127.0.0.1:1".to_string())
|
||||
return Err(Channel::from_shared("http://127.0.0.1:1".to_string())
|
||||
.unwrap()
|
||||
.connect()
|
||||
.await
|
||||
.expect_err("unreachable fallback endpoint should fail to connect"),
|
||||
);
|
||||
.expect_err("unreachable fallback endpoint should fail to connect"));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -187,13 +251,11 @@ impl RdbClient {
|
|||
} else if let Some(error) = last_error {
|
||||
return Err(error);
|
||||
} else {
|
||||
return Err(
|
||||
Channel::from_shared("http://127.0.0.1:1".to_string())
|
||||
return Err(Channel::from_shared("http://127.0.0.1:1".to_string())
|
||||
.unwrap()
|
||||
.connect()
|
||||
.await
|
||||
.expect_err("unreachable fallback endpoint should fail to connect"),
|
||||
);
|
||||
.expect_err("unreachable fallback endpoint should fail to connect"));
|
||||
};
|
||||
let channel = channel.expect("direct connect should produce a channel when selected");
|
||||
|
||||
|
|
@ -219,7 +281,13 @@ impl RdbClient {
|
|||
}
|
||||
|
||||
if let Some(chainfire_kv_client) = &self.chainfire_kv_client {
|
||||
return self.resolve_addr_via_chainfire(key, chainfire_kv_client.clone()).await;
|
||||
let route = self
|
||||
.resolve_route_via_chainfire(key, chainfire_kv_client.clone(), false)
|
||||
.await?;
|
||||
self.region_cache
|
||||
.update(route.region.clone(), route.leader.clone())
|
||||
.await;
|
||||
return Ok(route.leader.addr);
|
||||
}
|
||||
|
||||
if let Some(pd_client) = &self.pd_client {
|
||||
|
|
@ -244,7 +312,13 @@ impl RdbClient {
|
|||
self.invalidate_chainfire_route_cache().await;
|
||||
|
||||
if let Some(chainfire_kv_client) = &self.chainfire_kv_client {
|
||||
return self.resolve_addr_via_chainfire(key, chainfire_kv_client.clone()).await;
|
||||
let route = self
|
||||
.resolve_route_via_chainfire(key, chainfire_kv_client.clone(), true)
|
||||
.await?;
|
||||
self.region_cache
|
||||
.update(route.region.clone(), route.leader.clone())
|
||||
.await;
|
||||
return Ok(route.leader.addr);
|
||||
}
|
||||
|
||||
if let Some(pd_client) = &self.pd_client {
|
||||
|
|
@ -310,53 +384,21 @@ impl RdbClient {
|
|||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn resolve_addr_from_chainfire_snapshot(
|
||||
&self,
|
||||
key: &[u8],
|
||||
snapshot: &ChainfireRouteSnapshot,
|
||||
) -> Result<(Region, Store), tonic::Status> {
|
||||
let region = snapshot
|
||||
.regions
|
||||
.iter()
|
||||
.find(|region| {
|
||||
let start_ok = region.start_key.is_empty() || key >= region.start_key.as_slice();
|
||||
let end_ok = region.end_key.is_empty() || key < region.end_key.as_slice();
|
||||
start_ok && end_ok
|
||||
})
|
||||
.cloned()
|
||||
.ok_or_else(|| tonic::Status::not_found("region not found"))?;
|
||||
|
||||
let leader = snapshot
|
||||
.stores
|
||||
.get(®ion.leader_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| tonic::Status::not_found("leader store not found"))?;
|
||||
|
||||
Ok((
|
||||
Region {
|
||||
id: region.id,
|
||||
start_key: region.start_key,
|
||||
end_key: region.end_key,
|
||||
peers: region.peers,
|
||||
leader_id: region.leader_id,
|
||||
},
|
||||
Store {
|
||||
id: leader.id,
|
||||
addr: leader.addr,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
async fn with_routed_addr<T, F, Fut>(&self, key: &[u8], mut op: F) -> Result<T, tonic::Status>
|
||||
where
|
||||
F: FnMut(String) -> Fut,
|
||||
Fut: Future<Output = Result<T, tonic::Status>>,
|
||||
{
|
||||
let mut addr = self.resolve_addr(key).await?;
|
||||
let mut candidate_addrs = self.resolve_route_candidates(key, false).await?;
|
||||
let mut candidate_idx = 0usize;
|
||||
let mut refreshed = false;
|
||||
let mut last_status = None;
|
||||
|
||||
for attempt in 0..Self::ROUTE_RETRY_LIMIT {
|
||||
let addr = candidate_addrs
|
||||
.get(candidate_idx)
|
||||
.cloned()
|
||||
.ok_or_else(|| tonic::Status::internal("routing candidate list exhausted"))?;
|
||||
match tokio::time::timeout(Self::ROUTED_RPC_TIMEOUT, op(addr.clone())).await {
|
||||
Err(_) => {
|
||||
Self::evict_channel_from_map(&self.channels, &addr).await;
|
||||
|
|
@ -366,10 +408,19 @@ impl RdbClient {
|
|||
Self::ROUTED_RPC_TIMEOUT.as_millis()
|
||||
));
|
||||
|
||||
if candidate_idx + 1 < candidate_addrs.len() {
|
||||
candidate_idx += 1;
|
||||
last_status = Some(status);
|
||||
tokio::time::sleep(Self::retry_delay(attempt)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
if !refreshed && self.direct_addr.is_none() {
|
||||
refreshed = true;
|
||||
if let Ok(fresh_addr) = self.resolve_addr_uncached(key).await {
|
||||
addr = fresh_addr;
|
||||
if let Ok(fresh_candidates) = self.resolve_route_candidates(key, true).await
|
||||
{
|
||||
candidate_addrs = fresh_candidates;
|
||||
candidate_idx = 0;
|
||||
last_status = Some(status);
|
||||
tokio::time::sleep(Self::retry_delay(attempt)).await;
|
||||
continue;
|
||||
|
|
@ -391,20 +442,33 @@ impl RdbClient {
|
|||
.override_store_addr(key, redirect_addr.clone())
|
||||
.await;
|
||||
if redirect_addr != addr {
|
||||
addr = redirect_addr;
|
||||
candidate_addrs.retain(|candidate| candidate != &redirect_addr);
|
||||
candidate_addrs.insert(0, redirect_addr);
|
||||
candidate_idx = 0;
|
||||
last_status = Some(status);
|
||||
tokio::time::sleep(Self::retry_delay(attempt)).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (transport_error || Self::is_retryable_route_error(&status))
|
||||
&& candidate_idx + 1 < candidate_addrs.len()
|
||||
{
|
||||
candidate_idx += 1;
|
||||
last_status = Some(status);
|
||||
tokio::time::sleep(Self::retry_delay(attempt)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
if !refreshed
|
||||
&& self.direct_addr.is_none()
|
||||
&& Self::is_retryable_route_error(&status)
|
||||
{
|
||||
refreshed = true;
|
||||
if let Ok(fresh_addr) = self.resolve_addr_uncached(key).await {
|
||||
addr = fresh_addr;
|
||||
if let Ok(fresh_candidates) = self.resolve_route_candidates(key, true).await
|
||||
{
|
||||
candidate_addrs = fresh_candidates;
|
||||
candidate_idx = 0;
|
||||
last_status = Some(status);
|
||||
tokio::time::sleep(Self::retry_delay(attempt)).await;
|
||||
continue;
|
||||
|
|
@ -425,13 +489,54 @@ impl RdbClient {
|
|||
|
||||
return Err(status);
|
||||
}
|
||||
Ok(Ok(value)) => return Ok(value),
|
||||
Ok(Ok(value)) => {
|
||||
if candidate_idx > 0 {
|
||||
self.region_cache.override_store_addr(key, addr).await;
|
||||
}
|
||||
return Ok(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_status.unwrap_or_else(|| tonic::Status::internal("routing retry exhausted")))
|
||||
}
|
||||
|
||||
async fn resolve_route_candidates(
|
||||
&self,
|
||||
key: &[u8],
|
||||
force_refresh: bool,
|
||||
) -> Result<Vec<String>, tonic::Status> {
|
||||
if let Some(addr) = &self.direct_addr {
|
||||
return Ok(vec![addr.clone()]);
|
||||
}
|
||||
|
||||
if !force_refresh {
|
||||
if let Some(addr) = self.region_cache.get_store_addr(key).await {
|
||||
return Ok(vec![addr]);
|
||||
}
|
||||
} else {
|
||||
self.region_cache.clear().await;
|
||||
self.invalidate_chainfire_route_cache().await;
|
||||
}
|
||||
|
||||
if let Some(chainfire_kv_client) = &self.chainfire_kv_client {
|
||||
let route = self
|
||||
.resolve_route_via_chainfire(key, chainfire_kv_client.clone(), force_refresh)
|
||||
.await?;
|
||||
self.region_cache
|
||||
.update(route.region.clone(), route.leader.clone())
|
||||
.await;
|
||||
return Ok(route.candidate_addrs);
|
||||
}
|
||||
|
||||
let addr = if force_refresh {
|
||||
self.resolve_addr_uncached(key).await?
|
||||
} else {
|
||||
self.resolve_addr(key).await?
|
||||
};
|
||||
Ok(vec![addr])
|
||||
}
|
||||
|
||||
fn is_retryable_route_error(status: &tonic::Status) -> bool {
|
||||
if !matches!(
|
||||
status.code(),
|
||||
|
|
@ -454,9 +559,7 @@ impl RdbClient {
|
|||
}
|
||||
|
||||
fn retry_delay(attempt: usize) -> Duration {
|
||||
Duration::from_millis(
|
||||
Self::ROUTE_RETRY_BASE_DELAY_MS.saturating_mul((attempt as u64) + 1),
|
||||
)
|
||||
Duration::from_millis(Self::ROUTE_RETRY_BASE_DELAY_MS.saturating_mul((attempt as u64) + 1))
|
||||
}
|
||||
|
||||
fn is_transport_error(status: &tonic::Status) -> bool {
|
||||
|
|
@ -691,7 +794,11 @@ impl RdbClient {
|
|||
.into_iter()
|
||||
.map(|kv| (kv.key, kv.value, kv.version))
|
||||
.collect();
|
||||
let next = if resp.has_more { Some(resp.next_key) } else { None };
|
||||
let next = if resp.has_more {
|
||||
Some(resp.next_key)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok((entries, next))
|
||||
}
|
||||
})
|
||||
|
|
@ -727,20 +834,25 @@ impl RdbClient {
|
|||
.await
|
||||
}
|
||||
|
||||
async fn resolve_addr_via_chainfire(
|
||||
async fn resolve_route_via_chainfire(
|
||||
&self,
|
||||
key: &[u8],
|
||||
kv_client: ChainfireKvClient<Channel>,
|
||||
) -> Result<String, tonic::Status> {
|
||||
for force_refresh in [false, true] {
|
||||
force_refresh: bool,
|
||||
) -> Result<ResolvedRoute, tonic::Status> {
|
||||
if force_refresh {
|
||||
let snapshot = self
|
||||
.chainfire_route_snapshot(kv_client.clone(), force_refresh)
|
||||
.chainfire_route_snapshot(kv_client, true)
|
||||
.await?;
|
||||
if let Ok((region, leader)) =
|
||||
self.resolve_addr_from_chainfire_snapshot(key, &snapshot)
|
||||
{
|
||||
self.region_cache.update(region, leader.clone()).await;
|
||||
return Ok(leader.addr);
|
||||
return resolve_chainfire_route_from_snapshot(key, &snapshot);
|
||||
}
|
||||
|
||||
for refresh in [false, true] {
|
||||
let snapshot = self
|
||||
.chainfire_route_snapshot(kv_client.clone(), refresh)
|
||||
.await?;
|
||||
if let Ok(route) = resolve_chainfire_route_from_snapshot(key, &snapshot) {
|
||||
return Ok(route);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -833,7 +945,13 @@ async fn list_chainfire_regions(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{RdbClient, normalize_transport_addr, parse_transport_endpoints};
|
||||
use super::{
|
||||
normalize_transport_addr, parse_transport_endpoints,
|
||||
resolve_chainfire_route_from_snapshot, ChainfireRegionInfo, ChainfireRouteSnapshot,
|
||||
ChainfireStoreInfo, RdbClient,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
|
||||
#[test]
|
||||
fn unknown_transport_errors_are_treated_as_retryable_routes() {
|
||||
|
|
@ -864,4 +982,77 @@ mod tests {
|
|||
"10.0.0.1:2479".to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chainfire_routes_try_leader_then_other_peers() {
|
||||
let snapshot = ChainfireRouteSnapshot {
|
||||
stores: HashMap::from([
|
||||
(
|
||||
1,
|
||||
ChainfireStoreInfo {
|
||||
id: 1,
|
||||
addr: "10.0.0.1:2479".to_string(),
|
||||
},
|
||||
),
|
||||
(
|
||||
2,
|
||||
ChainfireStoreInfo {
|
||||
id: 2,
|
||||
addr: "10.0.0.2:2479".to_string(),
|
||||
},
|
||||
),
|
||||
(
|
||||
3,
|
||||
ChainfireStoreInfo {
|
||||
id: 3,
|
||||
addr: "10.0.0.3:2479".to_string(),
|
||||
},
|
||||
),
|
||||
]),
|
||||
regions: vec![ChainfireRegionInfo {
|
||||
id: 1,
|
||||
start_key: Vec::new(),
|
||||
end_key: Vec::new(),
|
||||
peers: vec![1, 2, 3],
|
||||
leader_id: 2,
|
||||
}],
|
||||
fetched_at: Instant::now(),
|
||||
};
|
||||
|
||||
let route = resolve_chainfire_route_from_snapshot(b"tenant/key", &snapshot).unwrap();
|
||||
assert_eq!(route.leader.id, 2);
|
||||
assert_eq!(
|
||||
route.candidate_addrs,
|
||||
vec![
|
||||
"10.0.0.2:2479".to_string(),
|
||||
"10.0.0.1:2479".to_string(),
|
||||
"10.0.0.3:2479".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chainfire_routes_fall_back_when_reported_leader_store_is_missing() {
|
||||
let snapshot = ChainfireRouteSnapshot {
|
||||
stores: HashMap::from([(
|
||||
1,
|
||||
ChainfireStoreInfo {
|
||||
id: 1,
|
||||
addr: "10.0.0.1:2479".to_string(),
|
||||
},
|
||||
)]),
|
||||
regions: vec![ChainfireRegionInfo {
|
||||
id: 1,
|
||||
start_key: Vec::new(),
|
||||
end_key: Vec::new(),
|
||||
peers: vec![1, 2],
|
||||
leader_id: 2,
|
||||
}],
|
||||
fetched_at: Instant::now(),
|
||||
};
|
||||
|
||||
let route = resolve_chainfire_route_from_snapshot(b"tenant/key", &snapshot).unwrap();
|
||||
assert_eq!(route.leader.id, 1);
|
||||
assert_eq!(route.candidate_addrs, vec!["10.0.0.1:2479".to_string()]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use flaredb_proto::kvrpc::kv_cas_server::KvCasServer;
|
||||
use flaredb_proto::kvrpc::kv_raw_server::KvRawServer;
|
||||
|
|
@ -17,8 +18,7 @@ use tokio::time::{sleep, Duration};
|
|||
use tonic::transport::{Certificate, Channel, Identity, Server, ServerTlsConfig};
|
||||
use tonic_health::server::health_reporter;
|
||||
use tracing::{info, warn}; // Import warn
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use anyhow::Result; // Import anyhow
|
||||
use tracing_subscriber::EnvFilter; // Import anyhow
|
||||
|
||||
mod heartbeat;
|
||||
mod merkle;
|
||||
|
|
@ -278,7 +278,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
peer_addrs.clone(),
|
||||
));
|
||||
|
||||
let service = service::KvServiceImpl::new(engine.clone(), namespace_manager.clone(), store.clone());
|
||||
let service =
|
||||
service::KvServiceImpl::new(engine.clone(), namespace_manager.clone(), store.clone());
|
||||
let raft_service = raft_service::RaftServiceImpl::new(store.clone(), server_config.store_id);
|
||||
let pd_endpoints = server_config.resolved_pd_endpoints();
|
||||
|
||||
|
|
@ -389,6 +390,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
let store_id = server_config.store_id;
|
||||
let server_addr_string = server_config.addr.to_string();
|
||||
tokio::spawn(async move {
|
||||
let mut last_reported_leaders: HashMap<u64, u64> = HashMap::new();
|
||||
let client = Arc::new(Mutex::new(
|
||||
ChainfirePdClient::connect_any(&pd_endpoints_for_task)
|
||||
.await
|
||||
|
|
@ -399,8 +401,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
let mut guard = client.lock().await;
|
||||
if let Some(ref mut c) = *guard {
|
||||
// Send heartbeat
|
||||
let heartbeat_ok =
|
||||
match c.heartbeat(store_id, server_addr_string.clone()).await {
|
||||
let heartbeat_ok = match c.heartbeat(store_id, server_addr_string.clone()).await
|
||||
{
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
warn!("Heartbeat failed: {}", e);
|
||||
|
|
@ -417,15 +419,38 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
// Report observed leader status so routing metadata converges
|
||||
// even when followers are the first nodes to notice a leadership change.
|
||||
let region_ids = store_clone.list_region_ids().await;
|
||||
let mut observed_regions = HashMap::new();
|
||||
for region_id in region_ids {
|
||||
if let Some(node) = store_clone.get_raft_node(region_id).await {
|
||||
if let Some(observed_leader) = node.leader_id().await {
|
||||
if let Err(e) = c.report_leader(region_id, observed_leader).await {
|
||||
warn!("Report leader failed: {}", e);
|
||||
observed_regions.insert(region_id, observed_leader);
|
||||
let previous = last_reported_leaders.get(®ion_id).copied();
|
||||
if previous != Some(observed_leader) {
|
||||
info!(
|
||||
region_id,
|
||||
previous_leader = ?previous,
|
||||
observed_leader,
|
||||
"Reporting FlareDB region leader to PD"
|
||||
);
|
||||
}
|
||||
match c.report_leader(region_id, observed_leader).await {
|
||||
Ok(_) => {
|
||||
last_reported_leaders.insert(region_id, observed_leader);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
region_id,
|
||||
observed_leader,
|
||||
error = %e,
|
||||
"Report leader failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
last_reported_leaders
|
||||
.retain(|region_id, _| observed_regions.contains_key(region_id));
|
||||
|
||||
// Refresh regions from PD (from cache, updated via watch)
|
||||
let regions = c.list_regions().await;
|
||||
|
|
@ -577,12 +602,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
|
||||
let tls = if tls_config.require_client_cert {
|
||||
info!("mTLS enabled, requiring client certificates");
|
||||
let ca_cert = tokio::fs::read(
|
||||
tls_config
|
||||
.ca_file
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("ca_file required when require_client_cert=true"))?,
|
||||
)
|
||||
let ca_cert = tokio::fs::read(tls_config.ca_file.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("ca_file required when require_client_cert=true")
|
||||
})?)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read CA file: {}", e))?;
|
||||
let ca = Certificate::from_pem(ca_cert);
|
||||
|
|
@ -650,6 +672,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
|
||||
fn init_logging(level: &str) {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level)))
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level)),
|
||||
)
|
||||
.init();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2633,6 +2633,91 @@ try_get_volume_json() {
|
|||
127.0.0.1:${vm_port} plasmavmc.v1.VolumeService/GetVolume
|
||||
}
|
||||
|
||||
start_plasmavmc_vm_watch() {
|
||||
local node="$1"
|
||||
local proto_root="$2"
|
||||
local token="$3"
|
||||
local org_id="$4"
|
||||
local project_id="$5"
|
||||
local vm_id="$6"
|
||||
local output_path="$7"
|
||||
|
||||
ssh_node_script "${node}" "${proto_root}" "${token}" "${org_id}" "${project_id}" "${vm_id}" "${output_path}" <<'EOS'
|
||||
set -euo pipefail
|
||||
proto_root="$1"
|
||||
token="$2"
|
||||
org_id="$3"
|
||||
project_id="$4"
|
||||
vm_id="$5"
|
||||
output_path="$6"
|
||||
rm -f "${output_path}" "${output_path}.pid" "${output_path}.stderr"
|
||||
nohup timeout 600 grpcurl -plaintext \
|
||||
-H "authorization: Bearer ${token}" \
|
||||
-import-path "${proto_root}/plasmavmc" \
|
||||
-proto "${proto_root}/plasmavmc/plasmavmc.proto" \
|
||||
-d "$(jq -cn --arg org "${org_id}" --arg project "${project_id}" --arg vm "${vm_id}" '{orgId:$org, projectId:$project, vmId:$vm}')" \
|
||||
127.0.0.1:50082 plasmavmc.v1.VmService/WatchVm \
|
||||
>"${output_path}" 2>"${output_path}.stderr" &
|
||||
echo $! >"${output_path}.pid"
|
||||
EOS
|
||||
}
|
||||
|
||||
wait_for_plasmavmc_vm_watch_completion() {
|
||||
local node="$1"
|
||||
local output_path="$2"
|
||||
local timeout="${3:-60}"
|
||||
local deadline=$((SECONDS + timeout))
|
||||
|
||||
while true; do
|
||||
if ssh_node_script "${node}" "${output_path}" <<'EOS'
|
||||
set -euo pipefail
|
||||
output_path="$1"
|
||||
if [[ ! -f "${output_path}.pid" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
pid="$(cat "${output_path}.pid")"
|
||||
if kill -0 "${pid}" >/dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
EOS
|
||||
then
|
||||
return 0
|
||||
fi
|
||||
if (( SECONDS >= deadline )); then
|
||||
ssh_node "${node}" "test -f ${output_path}.stderr && cat ${output_path}.stderr || true" >&2 || true
|
||||
ssh_node "${node}" "test -f ${output_path} && cat ${output_path} || true" >&2 || true
|
||||
die "timed out waiting for PlasmaVMC watch stream to exit"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
assert_plasmavmc_vm_watch_events() {
|
||||
local node="$1"
|
||||
local output_path="$2"
|
||||
local vm_id="$3"
|
||||
|
||||
ssh_node_script "${node}" "${output_path}" "${vm_id}" <<'EOS'
|
||||
set -euo pipefail
|
||||
output_path="$1"
|
||||
vm_id="$2"
|
||||
[[ -s "${output_path}" ]] || {
|
||||
echo "PlasmaVMC watch output is empty" >&2
|
||||
test -f "${output_path}.stderr" && cat "${output_path}.stderr" >&2 || true
|
||||
exit 1
|
||||
}
|
||||
jq -s --arg vm "${vm_id}" '
|
||||
any(.vmId == $vm and .eventType == "VM_EVENT_TYPE_STATE_CHANGED" and .vm.state == "VM_STATE_RUNNING") and
|
||||
any(.vmId == $vm and .eventType == "VM_EVENT_TYPE_STATE_CHANGED" and .vm.state == "VM_STATE_STOPPED") and
|
||||
any(.vmId == $vm and .eventType == "VM_EVENT_TYPE_DELETED")
|
||||
' "${output_path}" >/dev/null || {
|
||||
cat "${output_path}" >&2
|
||||
test -f "${output_path}.stderr" && cat "${output_path}.stderr" >&2 || true
|
||||
exit 1
|
||||
}
|
||||
EOS
|
||||
}
|
||||
|
||||
wait_requested() {
|
||||
local nodes
|
||||
mapfile -t nodes < <(all_or_requested_nodes "$@")
|
||||
|
|
@ -3707,6 +3792,7 @@ validate_vm_storage_flow() {
|
|||
node04_coronafs_tunnel="$(start_ssh_tunnel node04 25088 "${CORONAFS_API_PORT}")"
|
||||
node05_coronafs_tunnel="$(start_ssh_tunnel node05 35088 "${CORONAFS_API_PORT}")"
|
||||
local image_source_path=""
|
||||
local vm_watch_output=""
|
||||
local node01_proto_root="/var/lib/plasmavmc/test-protos"
|
||||
local vpc_id="" subnet_id="" port_id="" port_ip="" port_mac=""
|
||||
cleanup_vm_storage_flow() {
|
||||
|
|
@ -3737,6 +3823,9 @@ validate_vm_storage_flow() {
|
|||
if [[ -n "${image_source_path}" && "${image_source_path}" != /nix/store/* ]]; then
|
||||
ssh_node node01 "rm -f ${image_source_path}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ -n "${vm_watch_output}" ]]; then
|
||||
ssh_node node01 "rm -f ${vm_watch_output} ${vm_watch_output}.pid ${vm_watch_output}.stderr" >/dev/null 2>&1 || true
|
||||
fi
|
||||
stop_ssh_tunnel node05 "${node05_coronafs_tunnel}"
|
||||
stop_ssh_tunnel node04 "${node04_coronafs_tunnel}"
|
||||
stop_ssh_tunnel node01 "${coronafs_tunnel}"
|
||||
|
|
@ -3993,6 +4082,9 @@ EOS
|
|||
)"
|
||||
vm_id="$(printf '%s' "${create_response}" | jq -r '.id')"
|
||||
[[ -n "${vm_id}" && "${vm_id}" != "null" ]] || die "failed to create VM through PlasmaVMC"
|
||||
vm_watch_output="/tmp/plasmavmc-watch-${vm_id}.json"
|
||||
start_plasmavmc_vm_watch node01 "${node01_proto_root}" "${token}" "${org_id}" "${project_id}" "${vm_id}" "${vm_watch_output}"
|
||||
sleep 2
|
||||
|
||||
local get_vm_json
|
||||
get_vm_json="$(jq -cn --arg org "${org_id}" --arg project "${project_id}" --arg vm "${vm_id}" '{orgId:$org, projectId:$project, vmId:$vm}')"
|
||||
|
|
@ -4420,6 +4512,8 @@ EOS
|
|||
fi
|
||||
sleep 2
|
||||
done
|
||||
wait_for_plasmavmc_vm_watch_completion node01 "${vm_watch_output}" 60
|
||||
assert_plasmavmc_vm_watch_events node01 "${vm_watch_output}" "${vm_id}"
|
||||
wait_for_prismnet_port_detachment "${token}" "${org_id}" "${project_id}" "${subnet_id}" "${port_id}" >/dev/null
|
||||
|
||||
ssh_node "${node_id}" "bash -lc '[[ ! -d $(printf '%q' "$(vm_runtime_dir_path "${vm_id}")") ]]'"
|
||||
|
|
|
|||
|
|
@ -1,32 +1,32 @@
|
|||
//! PlasmaVMC control plane server binary
|
||||
|
||||
use clap::Parser;
|
||||
use iam_service_auth::AuthService;
|
||||
use metrics_exporter_prometheus::PrometheusBuilder;
|
||||
use plasmavmc_api::proto::image_service_server::ImageServiceServer;
|
||||
use plasmavmc_api::proto::node_service_server::NodeServiceServer;
|
||||
use plasmavmc_api::proto::node_service_client::NodeServiceClient;
|
||||
use plasmavmc_api::proto::volume_service_server::VolumeServiceServer;
|
||||
use plasmavmc_api::proto::node_service_server::NodeServiceServer;
|
||||
use plasmavmc_api::proto::vm_service_server::VmServiceServer;
|
||||
use plasmavmc_api::proto::volume_service_server::VolumeServiceServer;
|
||||
use plasmavmc_api::proto::{
|
||||
HeartbeatNodeRequest, HypervisorType as ProtoHypervisorType, NodeCapacity,
|
||||
NodeState as ProtoNodeState, VolumeDriverKind as ProtoVolumeDriverKind,
|
||||
};
|
||||
use plasmavmc_firecracker::FireCrackerBackend;
|
||||
use plasmavmc_hypervisor::HypervisorRegistry;
|
||||
use plasmavmc_kvm::KvmBackend;
|
||||
use plasmavmc_firecracker::FireCrackerBackend;
|
||||
use iam_service_auth::AuthService;
|
||||
use plasmavmc_server::config::ServerConfig;
|
||||
use plasmavmc_server::VmServiceImpl;
|
||||
use plasmavmc_server::watcher::{StateSynchronizer, StateWatcher, WatcherConfig};
|
||||
use plasmavmc_server::VmServiceImpl;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tonic::transport::{Certificate, Endpoint, Identity, Server, ServerTlsConfig};
|
||||
use tonic_health::server::health_reporter;
|
||||
use tonic::{Request, Status};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use std::time::Duration;
|
||||
use std::{collections::HashMap, fs};
|
||||
use tonic::transport::{Certificate, Endpoint, Identity, Server, ServerTlsConfig};
|
||||
use tonic::{Request, Status};
|
||||
use tonic_health::server::health_reporter;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
/// PlasmaVMC control plane server
|
||||
#[derive(Parser, Debug)]
|
||||
|
|
@ -175,7 +175,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
let contents = tokio::fs::read_to_string(&args.config).await?;
|
||||
toml::from_str(&contents)?
|
||||
} else {
|
||||
tracing::info!("Config file not found: {}, using defaults", args.config.display());
|
||||
tracing::info!(
|
||||
"Config file not found: {}, using defaults",
|
||||
args.config.display()
|
||||
);
|
||||
ServerConfig::default()
|
||||
};
|
||||
|
||||
|
|
@ -246,13 +249,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
tracing::debug!("FireCracker backend not available (missing kernel/rootfs paths)");
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Registered hypervisors: {:?}",
|
||||
registry.available()
|
||||
);
|
||||
tracing::info!("Registered hypervisors: {:?}", registry.available());
|
||||
|
||||
// Initialize IAM authentication service
|
||||
tracing::info!("Connecting to IAM server at {}", config.auth.iam_server_addr);
|
||||
tracing::info!(
|
||||
"Connecting to IAM server at {}",
|
||||
config.auth.iam_server_addr
|
||||
);
|
||||
let auth_service = AuthService::new(&config.auth.iam_server_addr)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to connect to IAM server: {}", e))?;
|
||||
|
|
@ -278,7 +281,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
|
||||
// Create services
|
||||
let vm_service = Arc::new(
|
||||
VmServiceImpl::new(registry, auth_service.clone(), config.auth.iam_server_addr.clone())
|
||||
VmServiceImpl::new(
|
||||
registry,
|
||||
auth_service.clone(),
|
||||
config.auth.iam_server_addr.clone(),
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
|
||||
|
|
@ -288,7 +295,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
.unwrap_or(false)
|
||||
{
|
||||
let config = WatcherConfig::default();
|
||||
let (watcher, rx) = StateWatcher::new(config);
|
||||
let (watcher, rx) = StateWatcher::new(vm_service.store(), config);
|
||||
let synchronizer = StateSynchronizer::new(vm_service.clone());
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = watcher.start().await {
|
||||
|
|
@ -307,7 +314,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
.and_then(|v| v.parse::<u64>().ok())
|
||||
{
|
||||
if secs > 0 {
|
||||
vm_service.clone().start_health_monitor(Duration::from_secs(secs));
|
||||
vm_service
|
||||
.clone()
|
||||
.start_health_monitor(Duration::from_secs(secs));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -321,9 +330,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
.ok()
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.unwrap_or(60);
|
||||
vm_service
|
||||
.clone()
|
||||
.start_node_health_monitor(
|
||||
vm_service.clone().start_node_health_monitor(
|
||||
Duration::from_secs(interval_secs),
|
||||
Duration::from_secs(timeout_secs),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -68,6 +68,9 @@ pub trait VmStore: Send + Sync {
|
|||
/// List all VMs for a tenant
|
||||
async fn list_vms(&self, org_id: &str, project_id: &str) -> StorageResult<Vec<VirtualMachine>>;
|
||||
|
||||
/// List all VMs across every tenant.
|
||||
async fn list_all_vms(&self) -> StorageResult<Vec<VirtualMachine>>;
|
||||
|
||||
/// Save a VM handle
|
||||
async fn save_handle(
|
||||
&self,
|
||||
|
|
@ -431,6 +434,17 @@ impl VmStore for FlareDBStore {
|
|||
Ok(vms)
|
||||
}
|
||||
|
||||
async fn list_all_vms(&self) -> StorageResult<Vec<VirtualMachine>> {
|
||||
let mut vms = Vec::new();
|
||||
for value in self.cas_scan_values("/plasmavmc/vms/").await? {
|
||||
if let Ok(vm) = serde_json::from_slice::<VirtualMachine>(&value) {
|
||||
vms.push(vm);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(vms)
|
||||
}
|
||||
|
||||
async fn save_handle(
|
||||
&self,
|
||||
org_id: &str,
|
||||
|
|
@ -580,7 +594,9 @@ impl VmStore for FlareDBStore {
|
|||
client
|
||||
.cas(key.as_bytes().to_vec(), value, expected_version)
|
||||
.await
|
||||
.map_err(|e| StorageError::FlareDB(format!("FlareDB CAS volume save failed: {}", e)))?
|
||||
.map_err(|e| {
|
||||
StorageError::FlareDB(format!("FlareDB CAS volume save failed: {}", e))
|
||||
})?
|
||||
};
|
||||
Ok(success)
|
||||
}
|
||||
|
|
@ -714,6 +730,10 @@ impl VmStore for FileStore {
|
|||
.collect())
|
||||
}
|
||||
|
||||
async fn list_all_vms(&self) -> StorageResult<Vec<VirtualMachine>> {
|
||||
Ok(self.load_state().unwrap_or_default().vms)
|
||||
}
|
||||
|
||||
async fn save_handle(
|
||||
&self,
|
||||
_org_id: &str,
|
||||
|
|
|
|||
|
|
@ -30,10 +30,11 @@ use plasmavmc_api::proto::{
|
|||
NodeState as ProtoNodeState, OsType as ProtoOsType, PrepareVmMigrationRequest, RebootVmRequest,
|
||||
RecoverVmRequest, RegisterExternalVolumeRequest, ResetVmRequest, ResizeVolumeRequest,
|
||||
StartVmRequest, StopVmRequest, UncordonNodeRequest, UpdateImageRequest, UpdateVmRequest,
|
||||
VirtualMachine, Visibility as ProtoVisibility, VmEvent, VmSpec as ProtoVmSpec,
|
||||
VmState as ProtoVmState, VmStatus as ProtoVmStatus, Volume as ProtoVolume,
|
||||
VolumeBacking as ProtoVolumeBacking, VolumeDriverKind as ProtoVolumeDriverKind,
|
||||
VolumeFormat as ProtoVolumeFormat, VolumeStatus as ProtoVolumeStatus, WatchVmRequest,
|
||||
VirtualMachine, Visibility as ProtoVisibility, VmEvent, VmEventType as ProtoVmEventType,
|
||||
VmSpec as ProtoVmSpec, VmState as ProtoVmState, VmStatus as ProtoVmStatus,
|
||||
Volume as ProtoVolume, VolumeBacking as ProtoVolumeBacking,
|
||||
VolumeDriverKind as ProtoVolumeDriverKind, VolumeFormat as ProtoVolumeFormat,
|
||||
VolumeStatus as ProtoVolumeStatus, WatchVmRequest,
|
||||
};
|
||||
use plasmavmc_hypervisor::HypervisorRegistry;
|
||||
use plasmavmc_types::{
|
||||
|
|
@ -247,6 +248,10 @@ impl VmServiceImpl {
|
|||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
pub fn store(&self) -> Arc<dyn VmStore> {
|
||||
Arc::clone(&self.store)
|
||||
}
|
||||
|
||||
fn to_status_code(err: plasmavmc_types::Error) -> Status {
|
||||
Status::internal(err.to_string())
|
||||
}
|
||||
|
|
@ -287,6 +292,61 @@ impl VmServiceImpl {
|
|||
.as_secs()
|
||||
}
|
||||
|
||||
fn watch_poll_interval() -> Duration {
|
||||
let poll_interval_ms = std::env::var("PLASMAVMC_VM_WATCH_POLL_INTERVAL_MS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or(500)
|
||||
.max(100);
|
||||
Duration::from_millis(poll_interval_ms)
|
||||
}
|
||||
|
||||
fn vm_values_differ(
|
||||
previous: &plasmavmc_types::VirtualMachine,
|
||||
current: &plasmavmc_types::VirtualMachine,
|
||||
) -> Result<bool, Status> {
|
||||
let previous = serde_json::to_value(previous).map_err(|error| {
|
||||
Status::internal(format!("failed to serialize VM snapshot: {error}"))
|
||||
})?;
|
||||
let current = serde_json::to_value(current).map_err(|error| {
|
||||
Status::internal(format!("failed to serialize VM snapshot: {error}"))
|
||||
})?;
|
||||
Ok(previous != current)
|
||||
}
|
||||
|
||||
fn build_vm_event(
|
||||
previous: Option<&plasmavmc_types::VirtualMachine>,
|
||||
current: Option<&plasmavmc_types::VirtualMachine>,
|
||||
) -> Result<Option<VmEvent>, Status> {
|
||||
let event_type = match (previous, current) {
|
||||
(None, Some(_)) => ProtoVmEventType::Created,
|
||||
(Some(_), None) => ProtoVmEventType::Deleted,
|
||||
(Some(previous), Some(current)) => {
|
||||
if previous.state != current.state
|
||||
|| previous.status.actual_state != current.status.actual_state
|
||||
{
|
||||
ProtoVmEventType::StateChanged
|
||||
} else if Self::vm_values_differ(previous, current)? {
|
||||
ProtoVmEventType::Updated
|
||||
} else {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
(None, None) => return Ok(None),
|
||||
};
|
||||
|
||||
let vm = current
|
||||
.or(previous)
|
||||
.ok_or_else(|| Status::internal("watch event builder received an empty VM snapshot"))?;
|
||||
|
||||
Ok(Some(VmEvent {
|
||||
vm_id: vm.id.to_string(),
|
||||
event_type: event_type as i32,
|
||||
vm: Some(Self::to_proto_vm(vm, vm.status.clone())),
|
||||
timestamp: Self::now_epoch() as i64,
|
||||
}))
|
||||
}
|
||||
|
||||
fn endpoint_host(endpoint: &str) -> Result<String, Status> {
|
||||
let authority = endpoint
|
||||
.split("://")
|
||||
|
|
@ -1925,6 +1985,7 @@ impl VmServiceImpl {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use plasmavmc_types::VmSpec;
|
||||
|
||||
#[test]
|
||||
fn unspecified_disk_cache_defaults_to_writeback() {
|
||||
|
|
@ -1945,6 +2006,47 @@ mod tests {
|
|||
prismnet_api::proto::DeviceType::None as i32
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_vm_event_classifies_lifecycle_changes() {
|
||||
let vm = plasmavmc_types::VirtualMachine::new(
|
||||
"watch-vm",
|
||||
"watch-org",
|
||||
"watch-project",
|
||||
VmSpec::default(),
|
||||
);
|
||||
|
||||
let created = VmServiceImpl::build_vm_event(None, Some(&vm))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(created.event_type, ProtoVmEventType::Created as i32);
|
||||
|
||||
let mut updated_vm = vm.clone();
|
||||
updated_vm.updated_at += 1;
|
||||
updated_vm
|
||||
.metadata
|
||||
.insert("note".to_string(), "changed".to_string());
|
||||
let updated = VmServiceImpl::build_vm_event(Some(&vm), Some(&updated_vm))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(updated.event_type, ProtoVmEventType::Updated as i32);
|
||||
|
||||
let mut running_vm = updated_vm.clone();
|
||||
running_vm.state = VmState::Running;
|
||||
running_vm.status.actual_state = VmState::Running;
|
||||
let state_changed = VmServiceImpl::build_vm_event(Some(&updated_vm), Some(&running_vm))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
state_changed.event_type,
|
||||
ProtoVmEventType::StateChanged as i32
|
||||
);
|
||||
|
||||
let deleted = VmServiceImpl::build_vm_event(Some(&running_vm), None)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(deleted.event_type, ProtoVmEventType::Deleted as i32);
|
||||
}
|
||||
}
|
||||
|
||||
impl StateSink for VmServiceImpl {
|
||||
|
|
@ -3742,7 +3844,7 @@ impl VmService for VmServiceImpl {
|
|||
vm_id = %req.vm_id,
|
||||
org_id = %req.org_id,
|
||||
project_id = %req.project_id,
|
||||
"WatchVm request (stub implementation)"
|
||||
"WatchVm request"
|
||||
);
|
||||
self.auth
|
||||
.authorize(
|
||||
|
|
@ -3752,8 +3854,68 @@ impl VmService for VmServiceImpl {
|
|||
)
|
||||
.await?;
|
||||
|
||||
// TODO: Implement VM watch via ChainFire watch
|
||||
Err(Status::unimplemented("VM watch not yet implemented"))
|
||||
let initial_vm = self
|
||||
.ensure_vm_loaded(&req.org_id, &req.project_id, &req.vm_id)
|
||||
.await
|
||||
.ok_or_else(|| Status::not_found("VM not found"))?;
|
||||
let poll_interval = Self::watch_poll_interval();
|
||||
let store = Arc::clone(&self.store);
|
||||
let org_id = req.org_id.clone();
|
||||
let project_id = req.project_id.clone();
|
||||
let vm_id = req.vm_id.clone();
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(32);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut last_seen = Some(initial_vm);
|
||||
let mut ticker = tokio::time::interval(poll_interval);
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
let next_vm = match tokio::time::timeout(
|
||||
STORE_OP_TIMEOUT,
|
||||
store.load_vm(&org_id, &project_id, &vm_id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(vm)) => vm,
|
||||
Ok(Err(error)) => {
|
||||
let _ = tx
|
||||
.send(Err(Status::unavailable(format!(
|
||||
"VM watch poll failed: {error}"
|
||||
))))
|
||||
.await;
|
||||
break;
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = tx
|
||||
.send(Err(Status::deadline_exceeded("VM watch poll timed out")))
|
||||
.await;
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let event = match Self::build_vm_event(last_seen.as_ref(), next_vm.as_ref()) {
|
||||
Ok(event) => event,
|
||||
Err(status) => {
|
||||
let _ = tx.send(Err(status)).await;
|
||||
break;
|
||||
}
|
||||
};
|
||||
if let Some(event) = event {
|
||||
if tx.send(Ok(event)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
match next_vm {
|
||||
Some(vm) => last_seen = Some(vm),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Response::new(ReceiverStream::new(rx)))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,23 @@
|
|||
//! ChainFire state watcher for PlasmaVMC
|
||||
//! Storage-backed state watcher for PlasmaVMC.
|
||||
//!
|
||||
//! Provides state synchronization across multiple PlasmaVMC instances
|
||||
//! by watching ChainFire for VM and handle changes made by other nodes.
|
||||
//! PlasmaVMC persists VM intent and node heartbeats in the shared metadata
|
||||
//! store. This watcher polls that store and mirrors external changes into each
|
||||
//! process-local cache so multiple control-plane or agent instances converge on
|
||||
//! the same durable view.
|
||||
|
||||
use chainfire_client::{Client as ChainFireClient, EventType, WatchEvent};
|
||||
use crate::storage::VmStore;
|
||||
use plasmavmc_types::{Node, VirtualMachine, VmHandle};
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::MissedTickBehavior;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
type VmSnapshot = HashMap<VmKey, VirtualMachine>;
|
||||
type NodeSnapshot = HashMap<String, Node>;
|
||||
|
||||
/// Event types from the state watcher
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum StateEvent {
|
||||
|
|
@ -39,255 +48,246 @@ pub enum StateEvent {
|
|||
vm_id: String,
|
||||
},
|
||||
/// A node was updated
|
||||
NodeUpdated {
|
||||
node_id: String,
|
||||
node: Node,
|
||||
},
|
||||
NodeUpdated { node_id: String, node: Node },
|
||||
/// A node was deleted
|
||||
NodeDeleted {
|
||||
node_id: String,
|
||||
},
|
||||
NodeDeleted { node_id: String },
|
||||
}
|
||||
|
||||
/// Configuration for the state watcher
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WatcherConfig {
|
||||
/// ChainFire endpoint
|
||||
pub chainfire_endpoint: String,
|
||||
/// Poll interval for metadata refresh.
|
||||
pub poll_interval: Duration,
|
||||
/// Channel buffer size for events
|
||||
pub buffer_size: usize,
|
||||
}
|
||||
|
||||
impl Default for WatcherConfig {
|
||||
fn default() -> Self {
|
||||
let poll_interval_ms = std::env::var("PLASMAVMC_STATE_WATCHER_POLL_INTERVAL_MS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or(1000)
|
||||
.max(100);
|
||||
Self {
|
||||
chainfire_endpoint: std::env::var("PLASMAVMC_CHAINFIRE_ENDPOINT")
|
||||
.unwrap_or_else(|_| "http://127.0.0.1:2379".to_string()),
|
||||
poll_interval: Duration::from_millis(poll_interval_ms),
|
||||
buffer_size: 256,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// State watcher that monitors ChainFire for external changes
|
||||
/// State watcher that monitors the shared VM store for external changes.
|
||||
pub struct StateWatcher {
|
||||
store: Arc<dyn VmStore>,
|
||||
config: WatcherConfig,
|
||||
event_tx: mpsc::Sender<StateEvent>,
|
||||
}
|
||||
|
||||
impl StateWatcher {
|
||||
/// Create a new state watcher and return the event receiver
|
||||
pub fn new(config: WatcherConfig) -> (Self, mpsc::Receiver<StateEvent>) {
|
||||
/// Create a new state watcher and return the event receiver.
|
||||
pub fn new(
|
||||
store: Arc<dyn VmStore>,
|
||||
config: WatcherConfig,
|
||||
) -> (Self, mpsc::Receiver<StateEvent>) {
|
||||
let (event_tx, event_rx) = mpsc::channel(config.buffer_size);
|
||||
(Self { config, event_tx }, event_rx)
|
||||
(
|
||||
Self {
|
||||
store,
|
||||
config,
|
||||
event_tx,
|
||||
},
|
||||
event_rx,
|
||||
)
|
||||
}
|
||||
|
||||
/// Start watching for state changes
|
||||
/// Start watching for state changes.
|
||||
///
|
||||
/// This spawns background tasks that watch:
|
||||
/// - `/plasmavmc/vms/` prefix for VM changes
|
||||
/// - `/plasmavmc/handles/` prefix for handle changes
|
||||
/// - `/plasmavmc/nodes/` prefix for node changes
|
||||
/// VM handles remain local process state because they point at ephemeral
|
||||
/// runtime artifacts such as QMP sockets and per-node directories. Durable
|
||||
/// VM intent and node heartbeats are the shared state synchronized here.
|
||||
pub async fn start(&self) -> Result<(), WatcherError> {
|
||||
info!("Starting PlasmaVMC state watcher");
|
||||
info!(
|
||||
poll_interval_ms = self.config.poll_interval.as_millis(),
|
||||
"Starting storage-backed PlasmaVMC state watcher"
|
||||
);
|
||||
|
||||
// Connect to ChainFire
|
||||
let mut client = ChainFireClient::connect(&self.config.chainfire_endpoint)
|
||||
.await
|
||||
.map_err(|e| WatcherError::Connection(e.to_string()))?;
|
||||
let mut vm_snapshot = self.load_vms().await?;
|
||||
let mut node_snapshot = self.load_nodes().await?;
|
||||
let mut ticker = tokio::time::interval(self.config.poll_interval);
|
||||
ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
|
||||
|
||||
// Start watching VMs
|
||||
let vm_watch = client
|
||||
.watch_prefix(b"/plasmavmc/vms/")
|
||||
.await
|
||||
.map_err(|e| WatcherError::Watch(e.to_string()))?;
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
|
||||
let event_tx_vm = self.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
Self::watch_loop(vm_watch, event_tx_vm, WatchType::Vm).await;
|
||||
});
|
||||
|
||||
// Connect again for second watch (each watch uses its own stream)
|
||||
let mut client2 = ChainFireClient::connect(&self.config.chainfire_endpoint)
|
||||
.await
|
||||
.map_err(|e| WatcherError::Connection(e.to_string()))?;
|
||||
|
||||
// Start watching handles
|
||||
let handle_watch = client2
|
||||
.watch_prefix(b"/plasmavmc/handles/")
|
||||
.await
|
||||
.map_err(|e| WatcherError::Watch(e.to_string()))?;
|
||||
|
||||
let event_tx_handle = self.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
Self::watch_loop(handle_watch, event_tx_handle, WatchType::Handle).await;
|
||||
});
|
||||
|
||||
// Connect again for node watch
|
||||
let mut client3 = ChainFireClient::connect(&self.config.chainfire_endpoint)
|
||||
.await
|
||||
.map_err(|e| WatcherError::Connection(e.to_string()))?;
|
||||
|
||||
let node_watch = client3
|
||||
.watch_prefix(b"/plasmavmc/nodes/")
|
||||
.await
|
||||
.map_err(|e| WatcherError::Watch(e.to_string()))?;
|
||||
|
||||
let event_tx_node = self.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
Self::watch_loop(node_watch, event_tx_node, WatchType::Node).await;
|
||||
});
|
||||
|
||||
info!("State watcher started successfully");
|
||||
Ok(())
|
||||
let next_vm_snapshot = match self.load_vms().await {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(error) => {
|
||||
warn!(error = %error, "Failed to refresh PlasmaVMC VM snapshot");
|
||||
continue;
|
||||
}
|
||||
|
||||
/// Watch loop for processing events
|
||||
async fn watch_loop(
|
||||
mut watch: chainfire_client::WatchHandle,
|
||||
event_tx: mpsc::Sender<StateEvent>,
|
||||
watch_type: WatchType,
|
||||
) {
|
||||
debug!(?watch_type, "Starting watch loop");
|
||||
|
||||
while let Some(event) = watch.recv().await {
|
||||
match Self::process_event(&event, &watch_type) {
|
||||
Ok(Some(state_event)) => {
|
||||
if event_tx.send(state_event).await.is_err() {
|
||||
warn!("Event receiver dropped, stopping watch loop");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
// Event was filtered or not relevant
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(?watch_type, error = %e, "Failed to process watch event");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!(?watch_type, "Watch loop ended");
|
||||
}
|
||||
|
||||
/// Process a watch event into a state event
|
||||
fn process_event(
|
||||
event: &WatchEvent,
|
||||
watch_type: &WatchType,
|
||||
) -> Result<Option<StateEvent>, WatcherError> {
|
||||
let key_str = String::from_utf8_lossy(&event.key);
|
||||
|
||||
// Parse the key to extract org_id, project_id, vm_id
|
||||
let (org_id, project_id, vm_id) = match watch_type {
|
||||
WatchType::Vm => parse_vm_key(&key_str)?,
|
||||
WatchType::Handle => parse_handle_key(&key_str)?,
|
||||
WatchType::Node => (String::new(), String::new(), parse_node_key(&key_str)?),
|
||||
};
|
||||
match diff_vm_snapshots(&vm_snapshot, &next_vm_snapshot) {
|
||||
Ok(events) => {
|
||||
for event in events {
|
||||
if self.event_tx.send(event).await.is_err() {
|
||||
info!("State watcher receiver dropped, stopping");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
vm_snapshot = next_vm_snapshot;
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(error = %error, "Failed to diff PlasmaVMC VM snapshot");
|
||||
}
|
||||
}
|
||||
|
||||
match event.event_type {
|
||||
EventType::Put => {
|
||||
match watch_type {
|
||||
WatchType::Vm => {
|
||||
let vm: VirtualMachine = serde_json::from_slice(&event.value)
|
||||
.map_err(|e| WatcherError::Deserialize(e.to_string()))?;
|
||||
Ok(Some(StateEvent::VmUpdated {
|
||||
org_id,
|
||||
project_id,
|
||||
vm_id,
|
||||
vm,
|
||||
}))
|
||||
let next_node_snapshot = match self.load_nodes().await {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(error) => {
|
||||
warn!(error = %error, "Failed to refresh PlasmaVMC node snapshot");
|
||||
continue;
|
||||
}
|
||||
WatchType::Handle => {
|
||||
let handle: VmHandle = serde_json::from_slice(&event.value)
|
||||
.map_err(|e| WatcherError::Deserialize(e.to_string()))?;
|
||||
Ok(Some(StateEvent::HandleUpdated {
|
||||
org_id,
|
||||
project_id,
|
||||
vm_id,
|
||||
handle,
|
||||
}))
|
||||
};
|
||||
match diff_node_snapshots(&node_snapshot, &next_node_snapshot) {
|
||||
Ok(events) => {
|
||||
for event in events {
|
||||
if self.event_tx.send(event).await.is_err() {
|
||||
info!("State watcher receiver dropped, stopping");
|
||||
return Ok(());
|
||||
}
|
||||
WatchType::Node => {
|
||||
let node: Node = serde_json::from_slice(&event.value)
|
||||
.map_err(|e| WatcherError::Deserialize(e.to_string()))?;
|
||||
Ok(Some(StateEvent::NodeUpdated {
|
||||
node_id: vm_id,
|
||||
node,
|
||||
}))
|
||||
}
|
||||
node_snapshot = next_node_snapshot;
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(error = %error, "Failed to diff PlasmaVMC node snapshot");
|
||||
}
|
||||
}
|
||||
}
|
||||
EventType::Delete => {
|
||||
match watch_type {
|
||||
WatchType::Vm => Ok(Some(StateEvent::VmDeleted {
|
||||
org_id,
|
||||
project_id,
|
||||
vm_id,
|
||||
})),
|
||||
WatchType::Handle => Ok(Some(StateEvent::HandleDeleted {
|
||||
org_id,
|
||||
project_id,
|
||||
vm_id,
|
||||
})),
|
||||
WatchType::Node => Ok(Some(StateEvent::NodeDeleted { node_id: vm_id })),
|
||||
}
|
||||
|
||||
async fn load_vms(&self) -> Result<VmSnapshot, WatcherError> {
|
||||
let vms = self
|
||||
.store
|
||||
.list_all_vms()
|
||||
.await
|
||||
.map_err(|error| WatcherError::Storage(error.to_string()))?;
|
||||
let mut snapshot = HashMap::with_capacity(vms.len());
|
||||
for vm in vms {
|
||||
snapshot.insert(VmKey::from_vm(&vm), vm);
|
||||
}
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
async fn load_nodes(&self) -> Result<NodeSnapshot, WatcherError> {
|
||||
let nodes = self
|
||||
.store
|
||||
.list_nodes()
|
||||
.await
|
||||
.map_err(|error| WatcherError::Storage(error.to_string()))?;
|
||||
let mut snapshot = HashMap::with_capacity(nodes.len());
|
||||
for node in nodes {
|
||||
snapshot.insert(node.id.to_string(), node);
|
||||
}
|
||||
Ok(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct VmKey {
|
||||
org_id: String,
|
||||
project_id: String,
|
||||
vm_id: String,
|
||||
}
|
||||
|
||||
impl VmKey {
|
||||
fn from_vm(vm: &VirtualMachine) -> Self {
|
||||
Self {
|
||||
org_id: vm.org_id.clone(),
|
||||
project_id: vm.project_id.clone(),
|
||||
vm_id: vm.id.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum WatchType {
|
||||
Vm,
|
||||
Handle,
|
||||
Node,
|
||||
fn diff_vm_snapshots(
|
||||
previous: &VmSnapshot,
|
||||
current: &VmSnapshot,
|
||||
) -> Result<Vec<StateEvent>, WatcherError> {
|
||||
let mut events = Vec::new();
|
||||
|
||||
for (key, vm) in current {
|
||||
let changed = match previous.get(key) {
|
||||
Some(old_vm) => values_differ(old_vm, vm)?,
|
||||
None => true,
|
||||
};
|
||||
if changed {
|
||||
events.push(StateEvent::VmUpdated {
|
||||
org_id: key.org_id.clone(),
|
||||
project_id: key.project_id.clone(),
|
||||
vm_id: key.vm_id.clone(),
|
||||
vm: vm.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for key in previous.keys() {
|
||||
if !current.contains_key(key) {
|
||||
events.push(StateEvent::VmDeleted {
|
||||
org_id: key.org_id.clone(),
|
||||
project_id: key.project_id.clone(),
|
||||
vm_id: key.vm_id.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
/// Parse VM key: /plasmavmc/vms/{org_id}/{project_id}/{vm_id}
|
||||
fn parse_vm_key(key: &str) -> Result<(String, String, String), WatcherError> {
|
||||
let parts: Vec<&str> = key.trim_start_matches('/').split('/').collect();
|
||||
if parts.len() < 5 || parts[0] != "plasmavmc" || parts[1] != "vms" {
|
||||
return Err(WatcherError::InvalidKey(key.to_string()));
|
||||
fn diff_node_snapshots(
|
||||
previous: &NodeSnapshot,
|
||||
current: &NodeSnapshot,
|
||||
) -> Result<Vec<StateEvent>, WatcherError> {
|
||||
let mut events = Vec::new();
|
||||
|
||||
for (node_id, node) in current {
|
||||
let changed = match previous.get(node_id) {
|
||||
Some(old_node) => values_differ(old_node, node)?,
|
||||
None => true,
|
||||
};
|
||||
if changed {
|
||||
events.push(StateEvent::NodeUpdated {
|
||||
node_id: node_id.clone(),
|
||||
node: node.clone(),
|
||||
});
|
||||
}
|
||||
Ok((
|
||||
parts[2].to_string(),
|
||||
parts[3].to_string(),
|
||||
parts[4].to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
for node_id in previous.keys() {
|
||||
if !current.contains_key(node_id) {
|
||||
events.push(StateEvent::NodeDeleted {
|
||||
node_id: node_id.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
/// Parse handle key: /plasmavmc/handles/{org_id}/{project_id}/{vm_id}
|
||||
fn parse_handle_key(key: &str) -> Result<(String, String, String), WatcherError> {
|
||||
let parts: Vec<&str> = key.trim_start_matches('/').split('/').collect();
|
||||
if parts.len() < 5 || parts[0] != "plasmavmc" || parts[1] != "handles" {
|
||||
return Err(WatcherError::InvalidKey(key.to_string()));
|
||||
}
|
||||
Ok((
|
||||
parts[2].to_string(),
|
||||
parts[3].to_string(),
|
||||
parts[4].to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Parse node key: /plasmavmc/nodes/{node_id}
|
||||
fn parse_node_key(key: &str) -> Result<String, WatcherError> {
|
||||
let parts: Vec<&str> = key.trim_start_matches('/').split('/').collect();
|
||||
if parts.len() < 3 || parts[0] != "plasmavmc" || parts[1] != "nodes" {
|
||||
return Err(WatcherError::InvalidKey(key.to_string()));
|
||||
}
|
||||
Ok(parts[2].to_string())
|
||||
fn values_differ<T: Serialize>(lhs: &T, rhs: &T) -> Result<bool, WatcherError> {
|
||||
let lhs =
|
||||
serde_json::to_value(lhs).map_err(|error| WatcherError::Serialize(error.to_string()))?;
|
||||
let rhs =
|
||||
serde_json::to_value(rhs).map_err(|error| WatcherError::Serialize(error.to_string()))?;
|
||||
Ok(lhs != rhs)
|
||||
}
|
||||
|
||||
/// Watcher errors
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum WatcherError {
|
||||
#[error("Connection error: {0}")]
|
||||
Connection(String),
|
||||
#[error("Watch error: {0}")]
|
||||
Watch(String),
|
||||
#[error("Invalid key format: {0}")]
|
||||
InvalidKey(String),
|
||||
#[error("Deserialization error: {0}")]
|
||||
Deserialize(String),
|
||||
#[error("Storage error: {0}")]
|
||||
Storage(String),
|
||||
#[error("Serialization error: {0}")]
|
||||
Serialize(String),
|
||||
}
|
||||
|
||||
/// State synchronizer that applies watch events to local state
|
||||
|
|
@ -323,20 +323,42 @@ impl<S: StateSink> StateSynchronizer<S> {
|
|||
|
||||
while let Some(event) = event_rx.recv().await {
|
||||
match event {
|
||||
StateEvent::VmUpdated { org_id, project_id, vm_id, vm } => {
|
||||
StateEvent::VmUpdated {
|
||||
org_id,
|
||||
project_id,
|
||||
vm_id,
|
||||
vm,
|
||||
} => {
|
||||
debug!(org_id, project_id, vm_id, "External VM update received");
|
||||
self.sink.on_vm_updated(&org_id, &project_id, &vm_id, vm);
|
||||
}
|
||||
StateEvent::VmDeleted { org_id, project_id, vm_id } => {
|
||||
StateEvent::VmDeleted {
|
||||
org_id,
|
||||
project_id,
|
||||
vm_id,
|
||||
} => {
|
||||
debug!(org_id, project_id, vm_id, "External VM deletion received");
|
||||
self.sink.on_vm_deleted(&org_id, &project_id, &vm_id);
|
||||
}
|
||||
StateEvent::HandleUpdated { org_id, project_id, vm_id, handle } => {
|
||||
StateEvent::HandleUpdated {
|
||||
org_id,
|
||||
project_id,
|
||||
vm_id,
|
||||
handle,
|
||||
} => {
|
||||
debug!(org_id, project_id, vm_id, "External handle update received");
|
||||
self.sink.on_handle_updated(&org_id, &project_id, &vm_id, handle);
|
||||
self.sink
|
||||
.on_handle_updated(&org_id, &project_id, &vm_id, handle);
|
||||
}
|
||||
StateEvent::HandleDeleted { org_id, project_id, vm_id } => {
|
||||
debug!(org_id, project_id, vm_id, "External handle deletion received");
|
||||
StateEvent::HandleDeleted {
|
||||
org_id,
|
||||
project_id,
|
||||
vm_id,
|
||||
} => {
|
||||
debug!(
|
||||
org_id,
|
||||
project_id, vm_id, "External handle deletion received"
|
||||
);
|
||||
self.sink.on_handle_deleted(&org_id, &project_id, &vm_id);
|
||||
}
|
||||
StateEvent::NodeUpdated { node_id, node } => {
|
||||
|
|
@ -357,33 +379,82 @@ impl<S: StateSink> StateSynchronizer<S> {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use plasmavmc_types::{VmSpec, VmState};
|
||||
|
||||
#[test]
|
||||
fn test_parse_vm_key() {
|
||||
let (org, proj, vm) = parse_vm_key("/plasmavmc/vms/org1/proj1/vm-123").unwrap();
|
||||
assert_eq!(org, "org1");
|
||||
assert_eq!(proj, "proj1");
|
||||
assert_eq!(vm, "vm-123");
|
||||
fn sample_vm() -> VirtualMachine {
|
||||
VirtualMachine::new("vm-a", "org-a", "project-a", VmSpec::default())
|
||||
}
|
||||
|
||||
fn sample_node() -> Node {
|
||||
Node::new("node-a")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_handle_key() {
|
||||
let (org, proj, vm) = parse_handle_key("/plasmavmc/handles/org1/proj1/vm-123").unwrap();
|
||||
assert_eq!(org, "org1");
|
||||
assert_eq!(proj, "proj1");
|
||||
assert_eq!(vm, "vm-123");
|
||||
fn vm_snapshot_diff_emits_update_for_new_and_changed_vms() {
|
||||
let mut previous = VmSnapshot::new();
|
||||
let mut current = VmSnapshot::new();
|
||||
let vm = sample_vm();
|
||||
let key = VmKey::from_vm(&vm);
|
||||
|
||||
current.insert(key.clone(), vm.clone());
|
||||
let events = diff_vm_snapshots(&previous, ¤t).unwrap();
|
||||
assert!(matches!(
|
||||
events.as_slice(),
|
||||
[StateEvent::VmUpdated { vm_id, .. }] if vm_id == &vm.id.to_string()
|
||||
));
|
||||
|
||||
previous = current.clone();
|
||||
let mut updated_vm = vm.clone();
|
||||
updated_vm.state = VmState::Running;
|
||||
updated_vm.status.actual_state = VmState::Running;
|
||||
current.insert(key, updated_vm.clone());
|
||||
let events = diff_vm_snapshots(&previous, ¤t).unwrap();
|
||||
assert!(matches!(
|
||||
events.as_slice(),
|
||||
[StateEvent::VmUpdated { vm, .. }] if vm.state == VmState::Running
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_node_key() {
|
||||
let node_id = parse_node_key("/plasmavmc/nodes/node-1").unwrap();
|
||||
assert_eq!(node_id, "node-1");
|
||||
fn vm_snapshot_diff_emits_delete_for_removed_vms() {
|
||||
let vm = sample_vm();
|
||||
let key = VmKey::from_vm(&vm);
|
||||
let previous = HashMap::from([(key, vm.clone())]);
|
||||
let current = VmSnapshot::new();
|
||||
|
||||
let events = diff_vm_snapshots(&previous, ¤t).unwrap();
|
||||
assert!(matches!(
|
||||
events.as_slice(),
|
||||
[StateEvent::VmDeleted { vm_id, .. }] if vm_id == &vm.id.to_string()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_key() {
|
||||
assert!(parse_vm_key("/invalid/key").is_err());
|
||||
assert!(parse_handle_key("/plasmavmc/wrong/a/b/c").is_err());
|
||||
assert!(parse_node_key("/plasmavmc/wrong").is_err());
|
||||
fn node_snapshot_diff_emits_update_and_delete() {
|
||||
let node = sample_node();
|
||||
let mut previous = NodeSnapshot::new();
|
||||
let mut current = NodeSnapshot::from([(node.id.to_string(), node.clone())]);
|
||||
|
||||
let events = diff_node_snapshots(&previous, ¤t).unwrap();
|
||||
assert!(matches!(
|
||||
events.as_slice(),
|
||||
[StateEvent::NodeUpdated { node_id, .. }] if node_id == &node.id.to_string()
|
||||
));
|
||||
|
||||
previous = current.clone();
|
||||
let mut updated_node = node.clone();
|
||||
updated_node.last_heartbeat = 42;
|
||||
current.insert(updated_node.id.to_string(), updated_node);
|
||||
let events = diff_node_snapshots(&previous, ¤t).unwrap();
|
||||
assert!(matches!(
|
||||
events.as_slice(),
|
||||
[StateEvent::NodeUpdated { node, .. }] if node.last_heartbeat == 42
|
||||
));
|
||||
|
||||
let events = diff_node_snapshots(¤t, &NodeSnapshot::new()).unwrap();
|
||||
assert!(matches!(
|
||||
events.as_slice(),
|
||||
[StateEvent::NodeDeleted { node_id }] if node_id == &node.id.to_string()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue