This commit is contained in:
rchac committed 2026-04-21 22:14:20 +00:00
1 parent c2262649fd
commit 823450f1d2
12 files changed
+340 -16

No files matched your search

+1
View File
@@ -14,6 +14,7 @@ Use esta integración cuando Sonar sea su fuente de verdad CRM/NMS.
- La integración refresca automáticamente los datos importados y de shaping que LibreQoS usa con Sonar.
- `network.json` queda para despliegues DIY o manuales.
- La integración importa IPs desde equipos de inventario, cuentas Radius e IPs asignadas directamente a la cuenta Sonar. Las IPs de Radius y de cuenta se agregan como dispositivos suplementarios de shaping y se deduplican contra las IPs de inventario.
- Use la WebUI para confirmar que la importación terminó correctamente.
## Referencia completa
+1
View File
@@ -118,6 +118,7 @@ Si el scheduler falla inmediatamente después de un reinicio con un mensaje como
Versiones recientes muestran estado/readiness del scheduler en WebUI.
Si el modal del scheduler indica que se agotó el tiempo al cargar detalles, las versiones actuales mantienen visible la última instantánea buena del scheduler con su antigüedad en lugar de convertir ese problema de transporte en un error del scheduler. Trate primero esa advertencia como un problema de comunicación entre WebUI y `lqosd`, y confirme la salud real del scheduler en los logs antes de asumir que falló el shaping.
Si falla un subproceso de integración pero el shaping puede continuar con la última topología válida, el scheduler puede seguir mostrando estado ready, pero el último error de integración permanece visible en el estado del scheduler hasta la siguiente integración exitosa.
Si aparece caído/desactualizado:
+1 -1
View File
@@ -7,7 +7,7 @@ Current behavior notes:
- Current builds page through Sonar GraphQL results instead of relying on a small first-page sample.
- Paginated Sonar GraphQL requests now use a split connect/read timeout and retry transient read timeouts before failing the import.
- Emitted Sonar identities are namespaced (for example `sonar:account:<id>` and `sonar:device:<id>`) so they remain stable across overrides and downstream tooling.
- Account device discovery now preserves inventory-item IP handling and also imports Radius account IP assignments when they exist. Inventory-backed MACs are still used for AP mapping; Radius-only IPs are added as supplemental shaping devices and overlapping subnets are de-duplicated.
- Account device discovery now preserves inventory-item IP handling and also imports Radius account and account-level IP assignments when they exist. Inventory-backed MACs are still used for AP mapping; Radius-only and account-level IPs are added as supplemental shaping devices and overlapping subnets are de-duplicated.
- Sonar `child_accounts` are also imported when they expose their own service and usable IP data. If a child account lacks its own address, LibreQoS falls back to the parent account address so the child can still be emitted as its own circuit.
- Sonar settings now support ISP-specific recurring-service fallback rates plus a recurring-service exclusion list. LibreQoS still prefers active `DATA` services first; recurring mappings are only used when an account has no usable `DATA` service.
- If Sonar returns non-JSON content or GraphQL errors, the integration now raises a more specific error message showing the endpoint and a short response preview.
+1 -1
View File
@@ -181,7 +181,7 @@ If `journalctl -u lqosd` shows repeated `BeginIngest queue full`, `IngestChunk q
If specific APs or switches appear multiple times with suffixed names such as `... [AP deadbeef]`, check whether UISP is returning duplicate rows for the same device ID. Current builds defensively deduplicate raw UISP devices by `identification.id` before topology graph construction, and skip any residual duplicate device IDs during graph assembly.
If an integration subprocess fails, current builds keep the scheduler alive, publish a shortened output preview to the scheduler status/error surfaces, and save the full captured output to a timestamped file under `/tmp` such as `lqos_scheduler_uisp_integration_YYYYMMDD_HHMMSS.log`.
If an integration subprocess fails, current builds keep the scheduler alive, publish a shortened output preview to the scheduler status/error surfaces, and save the full captured output to a timestamped file under `/tmp` such as `lqos_scheduler_uisp_integration_YYYYMMDD_HHMMSS.log`. If shaping can continue from the last-known-good topology, the scheduler may still report ready, but the latest integration failure remains visible in scheduler status until the next successful integration run.
### Scheduler status in WebUI looks unhealthy
+43 -2
View File
@@ -1,7 +1,7 @@
# Optimized version of integrationCommon.py for better performance with large datasets
# Provides common functionality shared between integrations.
from typing import List, Any, Dict, Set
from typing import List, Any, Dict, Set, Tuple
from liblqos_python import allowed_subnets, ignore_subnets, generated_pn_download_mbps, generated_pn_upload_mbps, \
circuit_name_use_address, upstream_bandwidth_capacity_download_mbps, upstream_bandwidth_capacity_upload_mbps, \
find_ipv6_using_mikrotik, exclude_sites, bandwidth_overhead_factor, committed_bandwidth_multiplier, \
@@ -32,6 +32,46 @@ def _state_directory():
def _state_path(category, filename):
return os.path.join(_state_directory(), category, filename)
def _normalize_exception_cpes(raw_exception_cpes) -> Tuple[Dict[str, str], List[str]]:
normalized = {}
errors = []
if raw_exception_cpes is None:
return normalized, errors
if isinstance(raw_exception_cpes, dict):
for cpe, parent in raw_exception_cpes.items():
cpe = str(cpe).strip()
parent = str(parent).strip()
if cpe and parent:
normalized[cpe] = parent
return normalized, errors
if not isinstance(raw_exception_cpes, (list, tuple, set)):
errors.append(
f"exception_cpes ignored: expected dict or list, got {type(raw_exception_cpes).__name__}"
)
return normalized, errors
for entry in raw_exception_cpes:
cpe = None
parent = None
if isinstance(entry, str):
if ":" in entry:
cpe, parent = entry.split(":", 1)
else:
errors.append(f"exception_cpes entry ignored: {entry!r} is missing ':'")
continue
elif isinstance(entry, dict):
cpe = entry.get("cpe")
parent = entry.get("parent")
else:
cpe = getattr(entry, "cpe", None)
parent = getattr(entry, "parent", None)
cpe = str(cpe or "").strip()
parent = str(parent or "").strip()
if not cpe or not parent:
errors.append(f"exception_cpes entry ignored: {entry!r} is missing cpe or parent")
continue
normalized[cpe] = parent
return normalized, errors
def isInAllowedSubnets(inputIP):
# Check whether an IP address occurs inside the allowedSubnets list
isAllowed = False
@@ -203,8 +243,9 @@ class NetworkGraph:
parentId="", displayName="Shaper Root")
]
self.excludeSites = exclude_sites()
self.exceptionCPEs = exception_cpes()
self.errors: List[str] = []
self.exceptionCPEs, exception_cpe_errors = _normalize_exception_cpes(exception_cpes())
self.errors.extend(exception_cpe_errors)
# Initialize optimization structures
self._id_to_index = {"FakeRoot": 0}
+66 -2
View File
@@ -65,6 +65,10 @@ def sonarRadiusAccountDeviceNodeId(radius_account_id):
return f"sonar:radius-account:{radius_account_id}"
def sonarAccountIpAssignmentDeviceNodeId(account_id):
return f"sonar:account-ip-assignments:{account_id}"
def sonarSession():
global _SONAR_SESSION
if _SONAR_SESSION is None:
@@ -236,6 +240,15 @@ def findRadiusAccountIPs(radius_account):
return dedupeSubnets(ips)
def findAccountIPs(account):
ips = []
for ip in account.get('ip_assignments', {}).get('entities', []):
subnet = ip.get('subnet')
if subnet:
ips.append(subnet)
return dedupeSubnets(ips)
def recurringServiceRateRules():
rules = {}
for enabled, service_name, download_mbps, upload_mbps in sonar_recurring_service_rates():
@@ -413,7 +426,8 @@ def buildAccountDevices(account):
'raw_id': item['id'],
'name': item['inventory_model']['name'],
'ips': ips,
'mac': findPrimaryMac(item)
'mac': findPrimaryMac(item),
'source': 'inventory_item',
})
for radius_account in account.get('radius_accounts', {}).get('entities', []):
@@ -429,7 +443,23 @@ def buildAccountDevices(account):
'raw_id': radius_account['id'],
'name': f"Radius Account {radius_account['id']}",
'ips': radius_ips,
'mac': ''
'mac': '',
'source': 'radius_account',
})
account_ips = [
subnet for subnet in findAccountIPs(account)
if subnet not in known_ip_subnets
]
if account_ips:
known_ip_subnets.update(account_ips)
devices.append({
'id': sonarAccountIpAssignmentDeviceNodeId(account['id']),
'raw_id': account['id'],
'name': 'Account IP Assignments',
'ips': account_ips,
'mac': '',
'source': 'account_ip_assignments',
})
return devices
@@ -520,6 +550,11 @@ def getAccounts(sonar_active_status_ids):
account_status_id
id
name
ip_assignments {
entities {
subnet
}
}
account_services (reverse_relation_filters: [$data]) {
entities {
service {
@@ -580,6 +615,11 @@ def getAccounts(sonar_active_status_ids):
entities {
id
name
ip_assignments {
entities {
subnet
}
}
account_services (reverse_relation_filters: [$data]) {
entities {
service {
@@ -729,6 +769,28 @@ def buildMacToApMap(aps):
mac_to_ap[cpe_mac] = ap['id']
return mac_to_ap
def sonarImportSummary(sites, aps, accounts):
total_devices = 0
devices_with_ips = 0
account_level_ip_devices = 0
accounts_with_parent = 0
for account in accounts:
if account.get('parent'):
accounts_with_parent += 1
for device in account.get('devices', []):
total_devices += 1
if len(device.get('ips') or []) > 0:
devices_with_ips += 1
if device.get('source') == 'account_ip_assignments':
account_level_ip_devices += 1
return (
f"Sonar import summary: sites={len(sites)} aps={len(aps)} "
f"accounts={len(accounts)} accounts_with_ap_parent={accounts_with_parent} "
f"devices={total_devices} devices_with_ips={devices_with_ips} "
f"account_level_ip_devices={account_level_ip_devices}"
)
def createShaper():
net = NetworkGraph()
@@ -757,6 +819,8 @@ def createShaper():
if account['parent']:
break
print(sonarImportSummary(sites, aps, accounts))
for site in sites:
net.addRawNode(NetworkNode(id=site['id'], displayName=site['name'], parentId="", type=NodeType.site, networkJsonId=f"sonar:site:{site['raw_id']}"))
+1 -1
View File
@@ -238,6 +238,6 @@ pub(crate) fn calculate_tree_weights() -> Result<Vec<NetworkNodeWeight>> {
});
});
result.sort_by(|a, b| b.weight.cmp(&a.weight));
result.sort_by_key(|entry| std::cmp::Reverse(entry.weight));
Ok(result)
}
+2
View File
@@ -2240,8 +2240,10 @@ fn committed_bandwidth_multiplier() -> PyResult<f32> {
/// A UISP exception CPE entry paired with its forced parent.
pub struct PyExceptionCpe {
/// Child CPE site or device identifier.
#[pyo3(get)]
pub cpe: String,
/// Parent identifier assigned to the exception CPE.
#[pyo3(get)]
pub parent: String,
}
+45 -8
View File
@@ -46,6 +46,7 @@ partial_topology_runtime_pending = False
partial_topology_runtime_generation = None
partial_topology_runtime_started_monotonic = None
partial_topology_runtime_last_report_state = None
last_integration_failure_message = ""
def configure_scheduler_stdio():
@@ -128,6 +129,37 @@ def clear_scheduler_output():
scheduler_output("")
def remember_integration_failure(message: str):
global last_integration_failure_message
last_integration_failure_message = str(message or "").strip()
def clear_integration_failure():
global last_integration_failure_message
last_integration_failure_message = ""
def clear_scheduler_error_unless_integration_failed():
if not last_integration_failure_message:
clear_scheduler_error()
def publish_ready_progress(active: bool, phase: str, phase_label: str, step_index: int, step_count: int, *, percent=None):
publish_scheduler_progress(
active,
phase,
phase_label,
step_index,
step_count,
percent=percent,
)
if last_integration_failure_message:
scheduler_error(
"Scheduler ready using last-known-good topology; latest integration import failed.\n"
+ last_integration_failure_message
)
def _scheduler_progress_percent(step_index: int, step_count: int, *, active: bool) -> int:
if step_count <= 0:
return 0
@@ -242,6 +274,7 @@ def _write_integration_output_artifact(label, output):
def _publish_integration_result(label, result):
output = ((result.stdout or "") + (result.stderr or "")).replace("\r\n", "\n").strip()
if result.returncode == 0:
clear_integration_failure()
line_count = len(_integration_output_lines(output))
summary = (
f"{label} completed successfully."
@@ -264,6 +297,7 @@ def _publish_integration_result(label, result):
if artifact is not None:
message += f"\nFull output saved to {artifact}"
print(message)
remember_integration_failure(message)
scheduler_error(message)
@@ -385,6 +419,7 @@ def run_python_integration(module_name: str, func_name: str, label: str = ""):
except Exception as e:
err = f"Failed to invoke integration {label or (module_name + '.' + func_name)}: {e}"
print(err)
remember_integration_failure(err)
scheduler_error(err)
def importFromCRM(
@@ -397,6 +432,7 @@ def importFromCRM(
overrides_label="Applying overrides",
overrides_step=3,
):
clear_integration_failure()
clear_scheduler_error()
clear_scheduler_output()
publish_scheduler_progress(
@@ -422,6 +458,7 @@ def importFromCRM(
except Exception as e:
error_msg = f"Failed to run UISP integration: {str(e)}"
print(error_msg)
remember_integration_failure(error_msg)
scheduler_error(error_msg)
elif automatic_import_splynx():
run_python_integration("integrationSplynx", "importFromSplynx", label="Splynx")
@@ -1086,7 +1123,7 @@ def importAndShapePartialReload():
shaping_runtime_hash = calculate_shaping_runtime_hash()
if shaping_runtime_hash != 0:
set_scheduler_status_bus_enabled(True)
publish_scheduler_progress(False, "ready", "Scheduler ready", SCHEDULER_REFRESH_STEP_COUNT, SCHEDULER_REFRESH_STEP_COUNT, percent=100)
publish_ready_progress(False, "ready", "Scheduler ready", SCHEDULER_REFRESH_STEP_COUNT, SCHEDULER_REFRESH_STEP_COUNT, percent=100)
def topology_runtime_status_path():
@@ -1197,7 +1234,7 @@ def report_topology_runtime_not_ready(
message += f" Generation {generation[:12]}."
print(message)
if _topology_runtime_not_ready_is_transient(detail):
clear_scheduler_error()
clear_scheduler_error_unless_integration_failed()
scheduler_output(message)
publish_scheduler_progress(
False,
@@ -1265,7 +1302,7 @@ def _defer_stale_shaping_inputs_failure(exc: Exception, *, startup: bool) -> boo
if detail and not _topology_runtime_not_ready_is_transient(detail):
return False
clear_scheduler_error()
clear_scheduler_error_unless_integration_failed()
if startup:
_begin_startup_topology_runtime_wait(generation if not ready else current_topology_source_generation())
else:
@@ -1412,8 +1449,8 @@ def _continue_startup_topology_runtime_wait():
_reset_startup_topology_runtime_wait()
if shaping_runtime_hash != 0:
clear_scheduler_error()
publish_scheduler_progress(
clear_scheduler_error_unless_integration_failed()
publish_ready_progress(
False,
"ready",
"Scheduler ready",
@@ -1522,8 +1559,8 @@ def _continue_partial_topology_runtime_wait():
)
return
_reset_partial_topology_runtime_wait()
clear_scheduler_error()
publish_scheduler_progress(
clear_scheduler_error_unless_integration_failed()
publish_ready_progress(
False,
"ready",
"Scheduler ready",
@@ -1629,7 +1666,7 @@ def run_scheduler_main():
try:
startup_ready = importAndShapeFullReload()
if startup_ready:
publish_scheduler_progress(False, "ready", "Scheduler ready", SCHEDULER_STARTUP_STEP_COUNT, SCHEDULER_STARTUP_STEP_COUNT, percent=100)
publish_ready_progress(False, "ready", "Scheduler ready", SCHEDULER_STARTUP_STEP_COUNT, SCHEDULER_STARTUP_STEP_COUNT, percent=100)
except ValidationFailure as e:
report_scheduler_validation_failure(
"Scheduler startup shaping refresh blocked by validation",
+39
View File
@@ -493,6 +493,45 @@ class TestGraph(unittest.TestCase):
net.prepareTree()
self.assertEqual(net.nodes[2].parentIndex, 1)
def test_empty_exception_cpes_list_does_not_crash(self):
import integrationCommon
from integrationCommon import NetworkGraph, NetworkNode, NodeType
original_exception_cpes = integrationCommon.exception_cpes
try:
integrationCommon.exception_cpes = lambda: []
net = NetworkGraph()
net.addRawNode(NetworkNode("Site_1", "Site_1", "", NodeType.site, 1000, 1000))
finally:
integrationCommon.exception_cpes = original_exception_cpes
self.assertEqual(net.nodes[1].displayName, "Site_1")
def test_exception_cpes_list_entries_are_normalized(self):
from integrationCommon import NetworkGraph, NetworkNode, NodeType, _normalize_exception_cpes
class ExceptionCpe:
cpe = "Site_3"
parent = "Site_1"
normalized, errors = _normalize_exception_cpes([
"Site_2:Site_1",
{"cpe": "Site_4", "parent": "Site_1"},
ExceptionCpe(),
])
self.assertEqual(errors, [])
self.assertEqual(normalized["Site_2"], "Site_1")
self.assertEqual(normalized["Site_3"], "Site_1")
self.assertEqual(normalized["Site_4"], "Site_1")
net = NetworkGraph()
net.exceptionCPEs = normalized
net.addRawNode(NetworkNode("Site_1", "Site_1", "", NodeType.site, 1000, 1000))
net.addRawNode(NetworkNode("Site_2", "Site_2", "", NodeType.site, 500, 500))
self.assertEqual(net.nodes[2].parentId, "Site_1")
def test_native_topology_editor_uses_nearest_real_infrastructure_parent(self):
from integrationCommon import NetworkGraph, NetworkNode, NodeType
+112 -1
View File
@@ -29,6 +29,9 @@ def install_sonar_stubs():
lqlib.sonar_active_status_ids = lambda: []
lqlib.sonar_recurring_service_rates = lambda: []
lqlib.sonar_recurring_excluded_service_names = lambda: []
lqlib.sonar_strategy = lambda: "full"
lqlib.write_compiled_topology_from_python_graph_payload = lambda *_args, **_kwargs: None
lqlib.get_libreqos_directory = lambda: ""
sys.modules["liblqos_python"] = lqlib
@@ -59,8 +62,13 @@ def inventory_item(item_id, name, ips=None, mac=""):
}
def sonar_account(address_items=None, radius_accounts=None):
def sonar_account(address_items=None, radius_accounts=None, account_ips=None):
return {
"id": 1,
"name": "Account 1",
"ip_assignments": {
"entities": [{"subnet": subnet} for subnet in (account_ips or [])]
},
"addresses": {
"entities": [
{
@@ -147,6 +155,53 @@ class TestIntegrationSonarDevices(unittest.TestCase):
self.assertEqual(devices[1]["id"], "sonar:radius-account:88")
self.assertEqual(devices[1]["ips"], ["100.64.3.31/32"])
def test_account_level_ips_emit_stable_account_ip_device(self):
devices = integrationSonar.buildAccountDevices(
sonar_account(
address_items=[
inventory_item(
42,
"ONU",
ips=[],
mac="AA:BB:CC:DD:EE:03",
)
],
account_ips=["100.64.8.80", "100.64.8.81"],
)
)
self.assertEqual(len(devices), 2)
self.assertEqual(devices[0]["id"], "sonar:device:42")
self.assertEqual(devices[0]["mac"], "AA:BB:CC:DD:EE:03")
self.assertEqual(devices[0]["ips"], [])
self.assertEqual(devices[1]["id"], "sonar:account-ip-assignments:1")
self.assertEqual(devices[1]["name"], "Account IP Assignments")
self.assertEqual(devices[1]["ips"], ["100.64.8.80", "100.64.8.81"])
self.assertEqual(devices[1]["mac"], "")
self.assertEqual(devices[1]["source"], "account_ip_assignments")
def test_account_level_ips_are_deduped_against_inventory_and_radius_ips(self):
devices = integrationSonar.buildAccountDevices(
sonar_account(
address_items=[
inventory_item(
42,
"ONU",
ips=["100.64.9.90"],
mac="AA:BB:CC:DD:EE:09",
)
],
radius_accounts=[
radius_account(99, ["100.64.9.91"])
],
account_ips=["100.64.9.90", "100.64.9.91", "100.64.9.92"],
)
)
self.assertEqual(len(devices), 3)
self.assertEqual(devices[2]["id"], "sonar:account-ip-assignments:1")
self.assertEqual(devices[2]["ips"], ["100.64.9.92"])
def test_inventory_mac_devices_are_preserved_when_radius_supplies_ips(self):
devices = integrationSonar.buildAccountDevices(
sonar_account(
@@ -195,6 +250,31 @@ class TestIntegrationSonarDevices(unittest.TestCase):
self.assertEqual(record["address"], "Parent Address")
self.assertEqual(record["id"], "sonar:account:500")
def test_child_account_imports_account_level_ips(self):
child = sonar_account(
address_items=[],
account_ips=["100.64.10.10"],
)
child["addresses"] = {"entities": []}
child["id"] = 501
child["name"] = "Suite 501"
child["account_services"] = {
"entities": [{
"service": {
"data_service_detail": {
"download_speed_kilobits_per_second": 100000,
"upload_speed_kilobits_per_second": 20000,
}
}
}]
}
record = integrationSonar.buildAccountRecord(child, fallback_address="Parent Address")
self.assertIsNotNone(record)
self.assertEqual(record["devices"][0]["id"], "sonar:account-ip-assignments:501")
self.assertEqual(record["devices"][0]["ips"], ["100.64.10.10"])
def test_child_accounts_are_added_without_duplicate_top_level_records(self):
top_level = sonar_account(
address_items=[
@@ -338,6 +418,37 @@ class TestIntegrationSonarDevices(unittest.TestCase):
self.assertIsNone(record)
def test_import_summary_counts_account_level_ip_devices(self):
account = {
"parent": "ap_1",
"devices": [
{
"id": "sonar:device:42",
"ips": [],
"source": "inventory_item",
},
{
"id": "sonar:account-ip-assignments:1",
"ips": ["100.64.11.11"],
"source": "account_ip_assignments",
},
],
}
summary = integrationSonar.sonarImportSummary(
sites=[{"id": "site_1"}],
aps=[{"id": "ap_1"}],
accounts=[account],
)
self.assertIn("sites=1", summary)
self.assertIn("aps=1", summary)
self.assertIn("accounts=1", summary)
self.assertIn("accounts_with_ap_parent=1", summary)
self.assertIn("devices=2", summary)
self.assertIn("devices_with_ips=1", summary)
self.assertIn("account_level_ip_devices=1", summary)
class FakeResponse:
def __init__(self, payload):
+28
View File
@@ -199,6 +199,7 @@ class TestSchedulerErrorReporting(unittest.TestCase):
scheduler.set_scheduler_status_bus_enabled(True)
scheduler._reset_startup_topology_runtime_wait()
scheduler._reset_partial_topology_runtime_wait()
scheduler.clear_integration_failure()
scheduler.shaping_runtime_hash = 0
def test_python_integration_output_does_not_set_scheduler_error(self):
@@ -296,12 +297,38 @@ class TestSchedulerErrorReporting(unittest.TestCase):
)
self.assertEqual(mock_scheduler_output.call_args_list, [(( "",),)])
def test_ready_progress_keeps_latest_integration_failure_visible(self):
scheduler.remember_integration_failure("Sonar exited with code 1. Continuing.")
with patch.object(scheduler, "publish_scheduler_progress") as mock_progress:
with patch.object(scheduler, "scheduler_error") as mock_scheduler_error:
scheduler.publish_ready_progress(
False,
"ready",
"Scheduler ready",
scheduler.SCHEDULER_REFRESH_STEP_COUNT,
scheduler.SCHEDULER_REFRESH_STEP_COUNT,
percent=100,
)
mock_progress.assert_called_once()
mock_scheduler_error.assert_called_once()
self.assertIn(
"Scheduler ready using last-known-good topology; latest integration import failed.",
mock_scheduler_error.call_args.args[0],
)
self.assertIn(
"Sonar exited with code 1. Continuing.",
mock_scheduler_error.call_args.args[0],
)
class TestSchedulerLogging(unittest.TestCase):
def setUp(self):
scheduler.set_scheduler_status_bus_enabled(True)
scheduler._reset_startup_topology_runtime_wait()
scheduler._reset_partial_topology_runtime_wait()
scheduler.clear_integration_failure()
scheduler.shaping_runtime_hash = 0
def test_configure_scheduler_stdio_enables_line_buffering_when_supported(self):
@@ -1025,6 +1052,7 @@ class TestTopologyRuntimeGating(unittest.TestCase):
def setUp(self):
scheduler._reset_startup_topology_runtime_wait()
scheduler._reset_partial_topology_runtime_wait()
scheduler.clear_integration_failure()
scheduler.shaping_runtime_hash = 0
def test_full_reload_skips_refresh_when_topology_runtime_not_ready(self):