mirror of
https://github.com/ruvnet/RuView
synced 2026-07-29 18:31:44 +00:00
220 lines
7.6 KiB
Rust
220 lines
7.6 KiB
Rust
//! Bounded startup restoration for registries and latest recorder states.
|
|
|
|
use std::path::Path;
|
|
|
|
use homecore::{DeviceEntry, EntityEntry, HomeCore};
|
|
use homecore_migrate::storage::read_envelope;
|
|
use homecore_recorder::{Recorder, RestoreWarning};
|
|
use serde::de::DeserializeOwned;
|
|
|
|
pub const MAX_REGISTRY_ENTRIES: usize = 100_000;
|
|
pub const MAX_REGISTRY_FILE_BYTES: u64 = 64 * 1024 * 1024;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum RestorePhase {
|
|
EntityRegistry,
|
|
DeviceRegistry,
|
|
States,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
pub struct StartupRestoreReport {
|
|
pub entity_entries: usize,
|
|
pub device_entries: usize,
|
|
pub states: usize,
|
|
pub warnings: Vec<String>,
|
|
pub truncated: bool,
|
|
pub phases: Vec<RestorePhase>,
|
|
}
|
|
|
|
pub async fn restore_startup(
|
|
homecore: &HomeCore,
|
|
recorder: Option<&Recorder>,
|
|
storage_dir: &Path,
|
|
limit: usize,
|
|
) -> StartupRestoreReport {
|
|
let limit = limit.min(MAX_REGISTRY_ENTRIES);
|
|
let mut report = StartupRestoreReport::default();
|
|
|
|
report.phases.push(RestorePhase::EntityRegistry);
|
|
let entities_path = storage_dir.join("core.entity_registry");
|
|
if entities_path.exists() {
|
|
match load_rows::<EntityEntry>(&entities_path, "entities", limit, |entry| {
|
|
entry.entity_id.as_str().to_owned()
|
|
}) {
|
|
Ok(batch) => {
|
|
report.truncated |= batch.truncated;
|
|
report.warnings.extend(batch.warnings);
|
|
for entry in batch.rows {
|
|
homecore.entities().register(entry).await;
|
|
report.entity_entries += 1;
|
|
}
|
|
}
|
|
Err(error) => report.warnings.push(error),
|
|
}
|
|
}
|
|
|
|
report.phases.push(RestorePhase::DeviceRegistry);
|
|
let devices_path = storage_dir.join("core.device_registry");
|
|
if devices_path.exists() {
|
|
match load_rows::<DeviceEntry>(&devices_path, "devices", limit, |entry| entry.id.clone()) {
|
|
Ok(batch) => {
|
|
report.truncated |= batch.truncated;
|
|
report.warnings.extend(batch.warnings);
|
|
for entry in batch.rows {
|
|
homecore.devices().register(entry).await;
|
|
report.device_entries += 1;
|
|
}
|
|
}
|
|
Err(error) => report.warnings.push(error),
|
|
}
|
|
}
|
|
|
|
report.phases.push(RestorePhase::States);
|
|
if let Some(recorder) = recorder {
|
|
match recorder.restore_latest(homecore.states(), limit).await {
|
|
Ok(state_report) => {
|
|
report.states = state_report.restored;
|
|
report.truncated |= state_report.truncated;
|
|
report
|
|
.warnings
|
|
.extend(state_report.warnings.into_iter().map(format_state_warning));
|
|
}
|
|
Err(error) => report
|
|
.warnings
|
|
.push(format!("state restore failed: {error}")),
|
|
}
|
|
}
|
|
report
|
|
}
|
|
|
|
struct RowBatch<T> {
|
|
rows: Vec<T>,
|
|
warnings: Vec<String>,
|
|
truncated: bool,
|
|
}
|
|
|
|
fn load_rows<T: DeserializeOwned>(
|
|
path: &Path,
|
|
field: &str,
|
|
limit: usize,
|
|
key: impl Fn(&T) -> String,
|
|
) -> Result<RowBatch<T>, String> {
|
|
let file_size = std::fs::metadata(path)
|
|
.map_err(|error| format!("{}: {error}", path.display()))?
|
|
.len();
|
|
if file_size > MAX_REGISTRY_FILE_BYTES {
|
|
return Err(format!(
|
|
"{}: registry file is {file_size} bytes, exceeding the {} byte startup bound",
|
|
path.display(),
|
|
MAX_REGISTRY_FILE_BYTES
|
|
));
|
|
}
|
|
let envelope = read_envelope(path).map_err(|error| error.to_string())?;
|
|
if envelope.version != 1 || envelope.minor_version > 13 {
|
|
return Err(format!(
|
|
"{}: unsupported registry version {}.{}",
|
|
path.display(),
|
|
envelope.version,
|
|
envelope.minor_version
|
|
));
|
|
}
|
|
let values = envelope
|
|
.data
|
|
.get(field)
|
|
.and_then(serde_json::Value::as_array)
|
|
.ok_or_else(|| format!("{}: missing data.{field} array", path.display()))?;
|
|
let scan_limit = limit.saturating_add(1).min(MAX_REGISTRY_ENTRIES + 1);
|
|
let mut rows = Vec::with_capacity(values.len().min(scan_limit));
|
|
let mut warnings = Vec::new();
|
|
for (index, value) in values.iter().take(scan_limit).enumerate() {
|
|
match serde_json::from_value::<T>(value.clone()) {
|
|
Ok(row) => rows.push(row),
|
|
Err(error) => warnings.push(format!(
|
|
"{}: isolated malformed data.{field}[{index}]: {error}",
|
|
path.display()
|
|
)),
|
|
}
|
|
}
|
|
rows.sort_by_key(&key);
|
|
let truncated = values.len() > limit || rows.len() > limit;
|
|
rows.truncate(limit);
|
|
Ok(RowBatch {
|
|
rows,
|
|
warnings,
|
|
truncated,
|
|
})
|
|
}
|
|
|
|
fn format_state_warning(warning: RestoreWarning) -> String {
|
|
format!("isolated malformed recorder row: {warning:?}")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use homecore::{Context, EntityId};
|
|
use homecore_recorder::Recorder;
|
|
|
|
#[tokio::test]
|
|
async fn restores_registries_before_states_and_isolates_bad_rows() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
std::fs::write(
|
|
dir.path().join("core.entity_registry"),
|
|
r#"{"version":1,"minor_version":13,"key":"core.entity_registry","data":{"entities":[
|
|
{"entity_id":"sensor.good","unique_id":null,"platform":"test","name":null,"disabled_by":null,"area_id":null,"device_id":"dev1","entity_category":null,"config_entry_id":null},
|
|
{"entity_id":"INVALID","platform":"bad"}
|
|
]}}"#,
|
|
)
|
|
.unwrap();
|
|
std::fs::write(
|
|
dir.path().join("core.device_registry"),
|
|
r#"{"version":1,"minor_version":13,"key":"core.device_registry","data":{"devices":[
|
|
{"id":"dev1","config_entries":[],"identifiers":[],"connections":[],"manufacturer":null,"model":null,"model_id":null,"name":"Device","name_by_user":null,"sw_version":null,"hw_version":null,"serial_number":null,"via_device_id":null,"area_id":null,"entry_type":null,"disabled_by":null,"configuration_url":null,"labels":[],"primary_config_entry":null}
|
|
]}}"#,
|
|
)
|
|
.unwrap();
|
|
let recorder = Recorder::open("sqlite::memory:").await.unwrap();
|
|
let source = HomeCore::new();
|
|
source.states().set(
|
|
EntityId::parse("sensor.good").unwrap(),
|
|
"on",
|
|
serde_json::json!({"restorable": true}),
|
|
Context::new(),
|
|
);
|
|
let mut receiver = source.states().subscribe();
|
|
// Force one recorder row from a real state event.
|
|
source.states().set(
|
|
EntityId::parse("sensor.good").unwrap(),
|
|
"off",
|
|
serde_json::json!({"restorable": true}),
|
|
Context::new(),
|
|
);
|
|
recorder
|
|
.record_state(&receiver.recv().await.unwrap())
|
|
.await
|
|
.unwrap();
|
|
|
|
let target = HomeCore::new();
|
|
let report =
|
|
restore_startup(&target, Some(&recorder), dir.path(), MAX_REGISTRY_ENTRIES).await;
|
|
assert_eq!(
|
|
report.phases,
|
|
vec![
|
|
RestorePhase::EntityRegistry,
|
|
RestorePhase::DeviceRegistry,
|
|
RestorePhase::States
|
|
]
|
|
);
|
|
assert_eq!(report.entity_entries, 1);
|
|
assert_eq!(report.device_entries, 1);
|
|
assert_eq!(report.states, 1);
|
|
assert!(!report.warnings.is_empty());
|
|
let state = target
|
|
.states()
|
|
.get(&EntityId::parse("sensor.good").unwrap())
|
|
.unwrap();
|
|
assert!(state.context.is_restoration());
|
|
}
|
|
}
|