From dc0f37a307bbfd83c52337a5e10a4804a805efe1 Mon Sep 17 00:00:00 2001 From: rchac Date: Tue, 5 May 2026 22:21:18 +0000 Subject: [PATCH] changes --- docs/v2.0/topology-data-flow.md | 4 + src/rust/lqos_topology/src/lib.rs | 252 +++++++++++++++++++++++++----- 2 files changed, 220 insertions(+), 36 deletions(-) diff --git a/docs/v2.0/topology-data-flow.md b/docs/v2.0/topology-data-flow.md index aedb8ad1..6dffd0e9 100644 --- a/docs/v2.0/topology-data-flow.md +++ b/docs/v2.0/topology-data-flow.md @@ -139,6 +139,10 @@ flowchart LR | `lqos_overrides.json` | operator / WebUI / CLI | scheduler / topology runtime | durable operator intent | Yes | Yes | Yes | | `topology_attachment_health_state.json` | topology probes | Topology Manager / debug pages | runtime attachment health | Runtime state | Runtime state | No | +## Runtime Fallback Warnings + +When a circuit references a parent or anchor that is not present in the queue-visible effective topology, LibreQoS still shapes the circuit under a generated parent node. Scheduler output summarizes those fallbacks by reason and includes a few example circuits instead of printing one warning per circuit. Review the examples in Topology Manager or the source integration when those circuits should attach to a specific real site, AP, or parent node. + ## Consumer Map ```{mermaid} diff --git a/src/rust/lqos_topology/src/lib.rs b/src/rust/lqos_topology/src/lib.rs index 9d773dcd..ae8a6da2 100644 --- a/src/rust/lqos_topology/src/lib.rs +++ b/src/rust/lqos_topology/src/lib.rs @@ -33,10 +33,134 @@ use std::thread; use std::time::{SystemTime, UNIX_EPOCH}; const TOPOLOGY_EFFECTIVE_PUBLISH_LOCK_FILENAME: &str = "topology_effective_publish.lock"; +const TOPOLOGY_WARNING_EXAMPLE_LIMIT: usize = 5; type EffectiveQueueAliasMap = HashMap; pub use runtime::start_topology; +#[derive(Default)] +struct TopologyFallbackWarningSummary { + unresolved_exported_anchor_count: usize, + unresolved_exported_anchor_examples: Vec, + missing_anchor_count: usize, + missing_anchor_examples: Vec, + missing_parent_count: usize, + missing_parent_examples: Vec, + non_exported_parent_count: usize, + non_exported_parent_examples: Vec, +} + +impl TopologyFallbackWarningSummary { + fn record_unresolved_exported_anchor( + &mut self, + circuit_id: &str, + anchor_id: &str, + anchor_name: &str, + ) { + self.unresolved_exported_anchor_count += 1; + push_capped_warning_example( + &mut self.unresolved_exported_anchor_examples, + format!("circuit '{circuit_id}' -> anchor '{anchor_id}' ('{anchor_name}')"), + ); + } + + fn record_missing_anchor( + &mut self, + circuit_id: &str, + anchor_id: &str, + anchor_name: Option<&str>, + ) { + self.missing_anchor_count += 1; + let example = if let Some(anchor_name) = anchor_name.filter(|value| !value.is_empty()) { + format!("circuit '{circuit_id}' -> anchor '{anchor_id}' ('{anchor_name}')") + } else { + format!("circuit '{circuit_id}' -> anchor '{anchor_id}'") + }; + push_capped_warning_example(&mut self.missing_anchor_examples, example); + } + + fn record_missing_parent(&mut self, circuit_id: &str, parent_name: &str, parent_id: &str) { + self.missing_parent_count += 1; + push_capped_warning_example( + &mut self.missing_parent_examples, + format!("circuit '{circuit_id}' -> parent '{parent_name}' ({parent_id})"), + ); + } + + fn record_non_exported_parent(&mut self, circuit_id: &str, parent_name: &str, parent_id: &str) { + self.non_exported_parent_count += 1; + push_capped_warning_example( + &mut self.non_exported_parent_examples, + format!("circuit '{circuit_id}' -> parent '{parent_name}' ({parent_id})"), + ); + } + + fn append_to(self, warnings: &mut Vec) { + push_fallback_summary_warning( + warnings, + self.unresolved_exported_anchor_count, + "anchor(s) that did not resolve to exported effective queue nodes", + &self.unresolved_exported_anchor_examples, + ); + push_fallback_summary_warning( + warnings, + self.missing_anchor_count, + "anchor(s) that were not found in the effective topology", + &self.missing_anchor_examples, + ); + push_fallback_summary_warning( + warnings, + self.missing_parent_count, + "parent reference(s) that were not found in the exported effective topology", + &self.missing_parent_examples, + ); + push_fallback_summary_warning( + warnings, + self.non_exported_parent_count, + "resolved parent(s) that were not exported effective queue nodes", + &self.non_exported_parent_examples, + ); + } +} + +fn push_capped_warning_example(examples: &mut Vec, example: String) { + if examples.len() < TOPOLOGY_WARNING_EXAMPLE_LIMIT { + examples.push(example); + } +} + +fn push_fallback_summary_warning( + warnings: &mut Vec, + count: usize, + reason: &str, + examples: &[String], +) { + if count == 0 { + return; + } + let mut warning = format!( + "{count} circuit(s) referenced {reason}. Falling back to generated parent-node shaping." + ); + if !examples.is_empty() { + warning.push_str(" Examples: "); + warning.push_str(&examples.join("; ")); + warning.push('.'); + let omitted_count = count.saturating_sub(examples.len()); + if omitted_count > 0 { + warning.push_str(&format!(" {omitted_count} more omitted.")); + } + } + warnings.push(warning); +} + +fn push_unresolved_runtime_fallback_summary(warnings: &mut Vec, count: usize) { + if count > 0 { + warnings.push(format!( + "{count} circuit(s) are unresolved in runtime topology and will be shaped under generated parent nodes." + )); + } +} + /// One unique probe pair emitted from topology state plus operator intent. #[derive(Clone, Debug, PartialEq, Eq)] pub struct AttachmentProbeSpec { @@ -547,6 +671,7 @@ fn build_shaping_inputs( let mut errors = Vec::new(); let mut warnings = Vec::new(); + let mut fallback_warning_summary = TopologyFallbackWarningSummary::default(); let mut circuits = Vec::::new(); let mut circuits_by_id = HashMap::::new(); @@ -612,12 +737,11 @@ fn build_shaping_inputs( ) { (Some(ui_node), Some(_effective_node), _) => { anchor_node_name = Some(ui_node.node_name.clone()); - warnings.push(format!( - "Circuit '{}' anchor '{}' ('{}') did not resolve to an exported effective queue node. Falling back to generated parent-node shaping.", - device.circuit_id, + fallback_warning_summary.record_unresolved_exported_anchor( + &device.circuit_id, anchor_id, - ui_node.node_name - )); + &ui_node.node_name, + ); ( String::new(), String::new(), @@ -627,15 +751,11 @@ fn build_shaping_inputs( ) } (None, None, Some(anchor)) => { - warnings.push(format!( - "Circuit '{}' anchor '{}' ('{}') was not found in the effective topology. Falling back to generated parent-node shaping.", - device.circuit_id, + fallback_warning_summary.record_missing_anchor( + &device.circuit_id, anchor_id, - anchor - .anchor_node_name - .as_deref() - .unwrap_or_default() - )); + anchor.anchor_node_name.as_deref(), + ); ( String::new(), String::new(), @@ -645,10 +765,11 @@ fn build_shaping_inputs( ) } _ => { - warnings.push(format!( - "Circuit '{}' anchor '{}' was not found in the effective topology. Falling back to generated parent-node shaping.", - device.circuit_id, anchor_id - )); + fallback_warning_summary.record_missing_anchor( + &device.circuit_id, + anchor_id, + None, + ); ( String::new(), String::new(), @@ -681,12 +802,11 @@ fn build_shaping_inputs( if optional_non_empty(&device.parent_node).is_some() || optional_non_empty_owned(device.parent_node_id.clone()).is_some() { - warnings.push(format!( - "Circuit '{}' parent reference '{}' ({}) was not found in the exported effective topology. Falling back to generated parent-node shaping.", - device.circuit_id, + fallback_warning_summary.record_missing_parent( + &device.circuit_id, device.parent_node.trim(), - device.parent_node_id.clone().unwrap_or_default().trim() - )); + device.parent_node_id.clone().unwrap_or_default().trim(), + ); } ( String::new(), @@ -707,10 +827,11 @@ fn build_shaping_inputs( &exported_effective_nodes, &exported_effective_names, ) { - warnings.push(format!( - "Circuit '{}' resolved to non-exported effective parent '{}' ({}). Falling back to generated parent-node shaping.", - device.circuit_id, effective_parent_node_name, effective_parent_node_id - )); + fallback_warning_summary.record_non_exported_parent( + &device.circuit_id, + &effective_parent_node_name, + &effective_parent_node_id, + ); effective_parent_node_id.clear(); effective_parent_node_name.clear(); effective_attachment_id = None; @@ -781,6 +902,7 @@ fn build_shaping_inputs( .devices .sort_unstable_by(|left, right| left.device_id.cmp(&right.device_id)); } + fallback_warning_summary.append_to(&mut warnings); let unresolved_runtime_fallbacks = circuits .iter() @@ -795,16 +917,7 @@ fn build_shaping_inputs( "Flat topology mode assigned {unresolved_runtime_fallbacks} circuit(s) to generated parent nodes during queue construction." )); } else { - for circuit in &circuits { - if circuit.effective_parent_node_id.trim().is_empty() - && circuit.resolution_source == TopologyShapingResolutionSource::RuntimeFallback - { - warnings.push(format!( - "Circuit '{}' is unresolved in runtime topology and will be shaped under generated parent nodes.", - circuit.circuit_id - )); - } - } + push_unresolved_runtime_fallback_summary(&mut warnings, unresolved_runtime_fallbacks); } } @@ -5399,6 +5512,73 @@ mod tests { ); } + #[test] + fn shaping_inputs_aggregate_missing_parent_warnings() { + let lqos_directory = unique_temp_dir("lqos-topology-missing-parent-summary"); + let config = Config { + lqos_directory: lqos_directory.to_string_lossy().to_string(), + state_directory: None, + ..Config::default() + }; + let mut csv = String::from( + "Circuit ID,Circuit Name,Device ID,Device Name,Parent Node,Parent Node ID,Anchor Node ID,MAC,IPv4,IPv6,Download Min Mbps,Upload Min Mbps,Download Max Mbps,Upload Max Mbps,Comment\n", + ); + for circuit_number in 1..=12 { + csv.push_str(&format!( + "\"circuit-{circuit_number}\",\"Circuit {circuit_number}\",\"device-{circuit_number}\",\"Device {circuit_number}\",\"Missing Parent\",\"missing-parent\",\"\",\"aa:bb:cc:dd:ee:{circuit_number:02x}\",\"192.0.2.{circuit_number}/32\",\"\",\"10\",\"10\",\"100\",\"100\",\"\"\n", + )); + } + fs::write(lqos_directory.join("ShapedDevices.csv"), csv) + .expect("ShapedDevices.csv should write"); + + let artifacts = EffectiveTopologyArtifacts { + effective: TopologyEffectiveStateFile { + schema_version: 1, + generated_unix: Some(1), + canonical_generated_unix: Some(1), + health_generated_unix: Some(1), + nodes: Vec::new(), + }, + ui_state: TopologyEditorStateFile { + schema_version: 1, + source: "test".to_string(), + generated_unix: Some(1), + ingress_identity: None, + nodes: Vec::new(), + }, + effective_network: Some(json!({})), + }; + + let shaping_inputs = build_shaping_inputs(&config, &artifacts) + .expect("shaping inputs should build") + .expect("shaping inputs should be present"); + assert_eq!(shaping_inputs.circuits.len(), 12); + assert!(shaping_inputs.circuits.iter().all(|circuit| { + circuit.resolution_source + == lqos_config::TopologyShapingResolutionSource::RuntimeFallback + })); + + let parent_warnings = shaping_inputs + .warnings + .iter() + .filter(|warning| warning.contains("parent reference(s)")) + .collect::>(); + assert_eq!(parent_warnings.len(), 1); + assert!(parent_warnings[0].contains("12 circuit(s)")); + assert!(parent_warnings[0].contains("circuit-1")); + assert!(parent_warnings[0].contains("circuit-5")); + assert!(!parent_warnings[0].contains("circuit-6")); + assert!(parent_warnings[0].contains("7 more omitted")); + assert_eq!( + shaping_inputs + .warnings + .iter() + .filter(|warning| warning.contains("unresolved in runtime topology")) + .count(), + 1 + ); + } + #[test] fn flat_mode_assigns_explicit_generated_parent_buckets() { let lqos_directory = unique_temp_dir("lqos-topology-flat-summary");