Commit 8b5d612648 for frr

commit 8b5d612648d9aadde2b925a7fdafd9db8dfeef2f
Author: Rafael Zalamena <rzalamena@opensourcerouting.org>
Date:   Fri Oct 9 21:37:31 2026 +0000

    tests: add frr-reload scaling test

    Add a BGP scale test to benchmark the configuration unload speed and
    measure unload time.

    On my setup the unload time on `master` without the unload batching is
    about 130 seconds, after the unload batching it goes down to 2 seconds.

    For safety margin the test checks if the unload time is less than 30
    seconds (15x more than what I measured and about 4x less than the
    previous time).

    Signed-off-by: Rafael Zalamena <rzalamena@opensourcerouting.org>

diff --git a/tests/topotests/frr_reload_bgp_scale/__init__.py b/tests/topotests/frr_reload_bgp_scale/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/topotests/frr_reload_bgp_scale/test_frr_reload_bgp_scale.py b/tests/topotests/frr_reload_bgp_scale/test_frr_reload_bgp_scale.py
new file mode 100644
index 0000000000..053e53d335
--- /dev/null
+++ b/tests/topotests/frr_reload_bgp_scale/test_frr_reload_bgp_scale.py
@@ -0,0 +1,295 @@
+#!/usr/bin/env python
+# SPDX-License-Identifier: ISC
+
+#
+# test_frr_reload_bgp_scale.py
+# Part of NetDEF Topology Tests
+#
+# Copyright (c) 2026 by
+# Network Device Education Foundation, Inc. ("NetDEF")
+#
+
+"""
+test_frr_reload_bgp_scale.py: time frr-reload.py unloading a big BGP
+configuration.
+
+r1 starts with a "router bgp" holding NEIGHBORS neighbors, each with
+LINES_PER_NEIGHBOR lines of configuration (half in the router context, half
+in "address-family ipv4 unicast"). Reloading the base configuration (the same
+"router bgp" without any neighbor) makes frr-reload.py delete every one of
+those lines, since the "router bgp" context itself stays.
+
+1. Unload: reload the base configuration and time it.
+2. Load: reload the full configuration and time it.
+3. Unload again: reload the base configuration and time it.
+
+After each reload the neighbor count must match and frr-reload.py --test
+must find nothing left to do. Each unload must finish within
+UNLOAD_TIMEOUT seconds. The times are logged and saved to
+<logdir>/r1/frr-reload-times.json.
+
+Knobs (override via env):
+  FRR_RELOAD_BGP_NEIGHBORS       number of BGP neighbors (500)
+  FRR_RELOAD_BGP_UNLOAD_TIMEOUT  unload budget in seconds (30)
+"""
+
+import json
+import os
+import sys
+import time
+
+import pytest
+
+CWD = os.path.dirname(os.path.realpath(__file__))
+sys.path.append(os.path.join(CWD, "../"))
+
+# pylint: disable=C0413
+from lib import topotest
+from lib.topogen import Topogen, get_topogen
+from lib.topolog import logger
+
+pytestmark = [pytest.mark.bgpd]
+
+NEIGHBORS = int(os.environ.get("FRR_RELOAD_BGP_NEIGHBORS", "500"))
+LINES_PER_NEIGHBOR = 10
+UNLOAD_TIMEOUT = float(os.environ.get("FRR_RELOAD_BGP_UNLOAD_TIMEOUT", "30"))
+
+# Lines frr-reload.py --test reports that do not come from the test
+IGNORED_DELTA = ("log commands", "no log commands", "domainname ", "no domainname ")
+
+TIMES = {}
+
+
+def neighbor_address(i):
+    "Neighbor addresses live in 10.0.0.0/16, on r1-eth0."
+    return f"10.0.{i // 250 + 1}.{i % 250 + 1}"
+
+
+def base_lines():
+    "Configuration kept by every reload: everything but the neighbors."
+    return [
+        "hostname r1",
+        "!",
+        "interface r1-eth0",
+        " ip address 10.0.0.1/16",
+        "exit",
+        "!",
+        "ip prefix-list PL-IN seq 5 permit 0.0.0.0/0 le 32",
+        "!",
+        "route-map RM-IN permit 10",
+        " match ip address prefix-list PL-IN",
+        "exit",
+        "!",
+        "route-map RM-OUT permit 10",
+        "exit",
+        "!",
+        "router bgp 65001",
+        " bgp router-id 10.254.254.1",
+        " no bgp ebgp-requires-policy",
+    ]
+
+
+def full_lines():
+    "Base configuration plus NEIGHBORS fully configured neighbors."
+    lines = base_lines()
+    for i in range(NEIGHBORS):
+        peer = neighbor_address(i)
+        lines += [
+            f" neighbor {peer} remote-as {65100 + i}",
+            f" neighbor {peer} description peer number {i}",
+            f" neighbor {peer} passive",
+            f" neighbor {peer} timers 10 30",
+            f" neighbor {peer} advertisement-interval 5",
+        ]
+    lines.append(" !")
+    lines.append(" address-family ipv4 unicast")
+    for i in range(NEIGHBORS):
+        peer = neighbor_address(i)
+        lines += [
+            f"  neighbor {peer} soft-reconfiguration inbound",
+            f"  neighbor {peer} allowas-in 1",
+            f"  neighbor {peer} maximum-prefix 1000",
+            f"  neighbor {peer} route-map RM-IN in",
+            f"  neighbor {peer} route-map RM-OUT out",
+        ]
+    lines.append(" exit-address-family")
+    lines.append("exit")
+    return lines
+
+
+def close_base(lines):
+    return lines + ["exit"]
+
+
+def conf_path(tgen, name):
+    path = os.path.join(tgen.logdir, "r1")
+    os.makedirs(path, exist_ok=True)
+    return os.path.join(path, name)
+
+
+def write_conf(path, lines):
+    with open(path, "w", encoding="ascii") as fh:
+        fh.write("\n".join(lines) + "\n")
+    return path
+
+
+def build_topo(tgen):
+    r1 = tgen.add_router("r1")
+
+    switch = tgen.add_switch("s1")
+    switch.add_link(r1)
+
+
+def setup_module(mod):
+    tgen = Topogen(build_topo, mod.__name__)
+    tgen.start_topology()
+
+    write_conf(conf_path(tgen, "frr-base.conf"), close_base(base_lines()))
+    full = write_conf(conf_path(tgen, "frr-full.conf"), full_lines())
+
+    for router in tgen.routers().values():
+        router.load_frr_config(full)
+
+    tgen.start_router()
+
+
+def teardown_module(mod):
+    tgen = get_topogen()
+    path = conf_path(tgen, "frr-reload-times.json")
+    with open(path, "w", encoding="ascii") as fh:
+        json.dump({"neighbors": NEIGHBORS, "times": TIMES}, fh, indent=2)
+    logger.info("frr-reload times saved to %s: %s", path, TIMES)
+    tgen.stop_topology()
+
+
+def frr_reload(router, conf, mode="--reload"):
+    tgen = get_topogen()
+    frrdir = tgen.config.get(tgen.CONFIG_SECTION, "frrdir")
+    start = time.monotonic()
+    rc, out, err = router.net.cmd_status(
+        f"{frrdir}/frr-reload.py {mode} --stdout {conf}", warn=False
+    )
+    elapsed = time.monotonic() - start
+    assert rc == 0, f"frr-reload.py {mode} {conf} failed (rc={rc}):\n{out}\n{err}"
+    return out, elapsed
+
+
+def reload_delta(router, conf):
+    """
+    Return the commands frr-reload.py --test would add and delete.
+
+    --test prints every command inside its contexts, closing each with
+    "exit", so only the leaves (lines not followed by a deeper line) are
+    commands. "log commands" and "domainname" are ignored: topotest daemons
+    always run with the first, take the second from the host, and the config
+    files have neither.
+    """
+    out, _ = frr_reload(router, conf, mode="--test")
+    sections = {"add": [], "delete": []}
+    section = None
+    for line in out.splitlines():
+        if line.startswith("Lines To Delete"):
+            section = "delete"
+        elif line.startswith("Lines To Add"):
+            section = "add"
+        elif section and line.strip() and not line.startswith("====="):
+            sections[section].append(line)
+
+    def depth(line):
+        return len(line) - len(line.lstrip())
+
+    delta = {}
+    for section, lines in sections.items():
+        delta[section] = [
+            line.strip()
+            for idx, line in enumerate(lines)
+            if line.strip() not in ("exit", "exit-address-family")
+            and (idx + 1 == len(lines) or depth(lines[idx + 1]) <= depth(line))
+            and not line.strip().startswith(IGNORED_DELTA)
+        ]
+    return delta
+
+
+def check_neighbor_count(router, expected):
+    def _check():
+        output = json.loads(router.vtysh_cmd("show bgp ipv4 unicast summary json"))
+        count = len(output.get("peers", {}))
+        if count != expected:
+            return f"{count} neighbors, expected {expected}"
+        return None
+
+    _, result = topotest.run_and_expect(_check, None, count=30, wait=1)
+    assert result is None, result
+
+
+def timed_reload(name, conf, neighbors, timeout=None):
+    tgen = get_topogen()
+    if tgen.routers_have_failure():
+        pytest.skip(tgen.errors)
+
+    r1 = tgen.gears["r1"]
+    delta = reload_delta(r1, conf)
+    _, elapsed = frr_reload(r1, conf)
+    TIMES[name] = {
+        "seconds": round(elapsed, 2),
+        "lines_to_add": len(delta["add"]),
+        "lines_to_delete": len(delta["delete"]),
+    }
+    logger.info(
+        "frr-reload.py %s: %.2fs (%d lines to add, %d lines to delete)",
+        name,
+        elapsed,
+        len(delta["add"]),
+        len(delta["delete"]),
+    )
+
+    check_neighbor_count(r1, neighbors)
+    leftover = reload_delta(r1, conf)
+    assert (
+        not leftover["add"] and not leftover["delete"]
+    ), f"unexpected delta after {name}: {leftover}"
+    assert not tgen.routers_have_failure(), tgen.errors
+    if timeout is not None:
+        assert (
+            elapsed <= timeout
+        ), f"frr-reload.py {name} took {elapsed:.2f}s, more than {timeout:.0f}s"
+
+
+def test_initial_config():
+    tgen = get_topogen()
+    if tgen.routers_have_failure():
+        pytest.skip(tgen.errors)
+
+    r1 = tgen.gears["r1"]
+    check_neighbor_count(r1, NEIGHBORS)
+    delta = reload_delta(r1, conf_path(tgen, "frr-full.conf"))
+    assert not delta["add"] and not delta["delete"], f"unexpected delta: {delta}"
+
+
+def test_unload():
+    tgen = get_topogen()
+    timed_reload("unload", conf_path(tgen, "frr-base.conf"), 0, UNLOAD_TIMEOUT)
+
+
+def test_load():
+    tgen = get_topogen()
+    timed_reload("load", conf_path(tgen, "frr-full.conf"), NEIGHBORS)
+
+
+def test_unload_again():
+    tgen = get_topogen()
+    timed_reload("unload_again", conf_path(tgen, "frr-base.conf"), 0, UNLOAD_TIMEOUT)
+
+
+def test_memory_leak():
+    "Run the memory leak test and report results."
+    tgen = get_topogen()
+    if not tgen.is_memleak_enabled():
+        pytest.skip("Memory leak test/report is disabled")
+
+    tgen.report_memory_leaks()
+
+
+if __name__ == "__main__":
+    args = ["-s"] + sys.argv[1:]
+    sys.exit(pytest.main(args))