On 10/23/26 3:30 PM, Dumitru Ceara wrote:
> Hi Rosemarie,
> 
> On 10/23/26 7:15 AM, Rosemarie O'Riorden wrote:
>> This commit adds a test that developers can run to check if the changes
>> they've made cause a regression in the performance of ovn-controller or
>> ovn-northd.
>>
>> Run from inside the sandbox and compare two commits' runtime and peak
>> memory usage for each process.
>>
>> This test creates a topology consisting of logical switches and routers,
>> ACLs, port groups, NAT, DHCP, DNS, QoS, routing policies, and load
>> balancers. The user can also provide their own database file for the
>> benchmark.
>>
>> * To run the test with defaults (200 nodes, track northd and controller):
>>     ./ovn-benchmark.sh
>> * To run with 50 nodes: ./ovn-benchmark.sh 50
>> * To run with 30 nodes and only track ovn-northd:
>>     ./ovn-benchmark.sh 30 northd
>> * To run with Valgrind Massif tracking allocations:
>>     ./ovn-benchmark.sh --valgrind
>> * To run with a custom db file: ./ovn-benchmark.sh -f file.db
>> * To run with debug info printed: ./ovn-benchmark.sh --debug
>> * To see detailed usage instructions: ./ovn-benchmark.sh --help
>>
>> Each "node" is a router-switch pair with associated features:
>>  - 1 gateway router (with NAT, static routes, routing policies)
>>  - 1 logical switch (with 9 ports by default, DHCP, DNS, QoS)
>>  - 5 load balancers (each with 5 backends)
>>  - ACLs, port groups, and address sets (shared across all nodes)
>>
>> For example, 200 nodes creates:
>>  - 200 routers + 200 switches
>>  - 1,800 logical switch ports (9 per switch)
>>  - 1,000 load balancers (5 per node)
>>  - 1,200 NAT rules, 1,200 static routes, 800 routing policies
>>  - Plus ACLs, DHCP/DNS records, QoS rules, etc.
>>
>> ovn-benchmark.py creates the topology and ovn-benchmark.sh is the
>> wrapper that tracks peak memory and execution time. ovn-benchmark.py was
>> built off of ovn-lb-benchmark.py as a starting point.
>>
>> VmPeak is tracked by default, but Valgrind can be used instead for more
>> precise results.
>>
>> Reported-at: https://redhat.atlassian.net/browse/FDP-978
>> Assisted-by: Claude Sonnet 4.5, Claude Code
>> Signed-off-by: Rosemarie O'Riorden <[email protected]>
>> ---
>> BTW:
>> A CI component for this patch, which would add a GH action that runs the
>> benchmark on the base upstream vs with the patch applied, and compares
>> the results to test for regressions, is coming in the future.
>>
>> v4 -> v5:
>>  - Incremental engine coverage:
>>    - Added 21 incremental phases (add/modify/delete) that run after
>>      topology creation: Each phase operates on ~10% of nodes, and is
>>      followed by an hv sync. These phases are timed.
>>    - After each phase, queries inc-engine/show-stats to check whether
>>      northd or lflow triggered a full recompute.  Phases that use NULL
>>      handlers (routes, policies, DHCP, DNS, QoS, LB disassociation)
>>      are marked as expected recomputes; unexpected recomputes print a
>>      WARNING. Failed phases print FAILED. If the results are unknown,
>>      "?" prints. Otherwise, "OK" prints.
>>  - Naming:
>>    - Renamed gateway routers from lr-X to gw-X (ports lr2j/j2lr to
>>      gw2j/j2gw).  Topology diagram updated.
>>    - Renamed all initial creation functions to use create_* prefix;
>>      incremental functions use add_/modify_/delete_ prefixes.
>>  - Timing:
>>    - Removed per-sub-phase creation timing (DB generation, port
>>      binding, initial sync) because they don't convey any useful info.
>>    - Removed DB compaction timing from output (compaction still runs
>>      for accurate memory readings).
>>    - Incremental phases show per-phase timing alongside engine stats.
>>  - Output:
>>    - Config summary moved from stderr to stdout.
>>    - Debug prints now show actual IP addresses, MAC addresses, and
>>      operation details instead of just "Adding NAT rules to router 0".
>>  - Shell script:
>>    - Duplicate process detection now reports all duplicate PIDs before
>>      exiting, not just the first.
>>    - Engine stats use globbed control socket path
>>      (sandbox/ovn-northd.*.ctl) instead of hardcoded path.
>>  - Syntax and validation:
>>    - Replaced sys.stderr.write()/sys.exit(1) with die() throughout.
>>    - Added minimum bounds checks (nodes >= 1, ports/vips/backends >= 0).
>>    - str.removesuffix() replaced with [:-3] for Python 3.7.
>>    - Port groups dict built once and passed to both consumers.
>>    - Transaction commit helper accepts UNCHANGED as valid.
>>    - Guards for ports_per_switch=0 edge case in add/delete LSP phases.
>>
>> v3 -> v4:
>>  - Update commit message to reflect # of NAT rules and static routes for IPv6
>>  - Remove python 3.9 syntax (str.removesuffix()), change out for
>>    python 3.7 (subnet[:-3])
>>  - Add timeout for ovn-nbctl --wait=hv sync in time tracking part of
>>    benchmark, and add warning if timeout is hit.
>>
>> v2 -> v3:
>>  - Memory tracking:
>>    - Dropped the background watcher loop that read RSS periodically.
>>      Now VmPeak is read once from /proc/<pid>/status instead, so short
>>      peaks aren't missed.
>>    - Added --valgrind (-v) to rerun the tracked processes under Massif
>>      and report peak heap.  More accurate but 10-50x slower, so it
>>      warns above 500 nodes.
>>    - The processes valgrind hijacks get restarted on exit, Ctrl-C
>>      included, from their saved cmdline and cwd.
>>  - Timing:
>>    - Total time is split into topology generation, OVN sync and db
>>      compaction, so you can tell which phase regressed.
>>  - Topology fixes:
>>    - DHCP options were created but never attached to any port.  They're
>>      assigned now, v4 and v6.
>>    - Same for the web/db/app address sets -- nothing referenced them, so
>>      added ACLs that do.
>>    - Split trusted_networks into v4 and v6 sets; one mixed set can't
>>      serve both the ip4.dst and ip6.dst matches.
>>    - Gateway join ports all shared one MAC and 10.0.0.1/8.  Each gets
>>      its own MAC and a real join subnet address now, + routes to match.
>>    - The port group drop ACL matches outport instead of inport.
>>    - Chassis names start at 1 to match the sandbox system-id.
>>  - Safety:
>>    - A missing router, switch, or port group used to be skipped quietly,
>>      leaving a half-built topology.  These error out now.
>>    - Errors out on duplicate processe and script running outside the sandbox.
>>    - Added a SIGINT trap, bounds checks for --backends and --vips, and a
>>      fix for binding more ports than there are nodes.
>>  - Docs:
>>    - testing.rst covers both memory modes and the valgrind tradeoff.
>>  - Small stuff:
>>    - Removed an unused json RPC connection.
>>    - Port group lookups use a dict instead of rescanning the table.
>>    - Better warnings for misused args, and options that may be confusing.
>>    - Quoting, output formatting, comments, etc.
>>
>> v1 -> v2:
>>  - Topology:
>>    - Restructured from 1:1 chassis:node to a nodes-per-chassis style
>>      where each chassis handles a partition or 'batch' of the network (ascii
>>      diagram added).
>>    - Added --batch-size (-b) for nodes-per-chassis grouping.
>>    - Added IPv6 coverage.
>>  - IP addressing:
>>    - ip_node()/ip6_node() helpers spread index across two octets to support
>>      >255 nodes.
>>    - Added bounds checks for node count and ports-per-switch.
>>  - Shell fixes:
>>    - pgrep -x for exact process match.
>>    - break 2 to exit both watcher loops on process death.
>>    - trap cleanup EXIT with mktemp -d for temp files.
>>    - Renamed SECONDS bc it's a bash keyword.
>>    - python -> python3.
>>    - Quoted array expansions, fixed PID error messages.
>>  - Code cleanup:
>>    - Extracted _add_node(), _add_ports(), _add_acl() helpers.
>>    - Removed dead code (find_by_name(), seqno).
>>    - Fixed error variable shadowing imported module 'error'.
>>  - Docs:
>>    - Added ovn-benchmark section to testing.rst.
>>    - Added note in submitting-patches.rst.
>> ---
>>  .../contributing/submitting-patches.rst       |    4 +
>>  Documentation/topics/testing.rst              |   39 +
>>  tutorial/automake.mk                          |    4 +-
>>  tutorial/ovn-benchmark.py                     | 1449 +++++++++++++++++
>>  tutorial/ovn-benchmark.sh                     |  561 +++++++
>>  5 files changed, 2056 insertions(+), 1 deletion(-)
>>  create mode 100755 tutorial/ovn-benchmark.py
>>  create mode 100755 tutorial/ovn-benchmark.sh
>>
>> diff --git a/Documentation/internals/contributing/submitting-patches.rst 
>> b/Documentation/internals/contributing/submitting-patches.rst
>> index abeb3e9d0..c96fdff84 100644
>> --- a/Documentation/internals/contributing/submitting-patches.rst
>> +++ b/Documentation/internals/contributing/submitting-patches.rst
>> @@ -68,6 +68,10 @@ Testing is also important:
>>    feature.  A bug fix patch should preferably add a test that would
>>    fail if the bug recurs.
>>  
>> +- To check for memory or performance regressions, you can run
>> +  ``./ovn-benchmark.sh`` inside the OVN sandbox (``make sandbox``).
>> +  See the "ovn-benchmark" section of :doc:`/topics/testing` for details.
>> +
>>  If you are using GitHub, then you may utilize the GitHub Actions CI system.
>>  This will run the above tests automatically when you push changes to your
>>  repository.
>> diff --git a/Documentation/topics/testing.rst 
>> b/Documentation/topics/testing.rst
>> index 951133c29..18508ba62 100644
>> --- a/Documentation/topics/testing.rst
>> +++ b/Documentation/topics/testing.rst
>> @@ -294,6 +294,45 @@ of these cached objects, be sure to rebuild the test.
>>  The cached objects are stored under the relevant folder in
>>  ``tests/perf-testsuite.dir/cached``.
>>  
>> +ovn-benchmark
>> ++++++++++++++
>> +
>> +The ``tutorial/`` directory contains a memory and performance benchmarking
>> +tool that can be used to detect regressions between commits:
>> +
>> +- ``ovn-benchmark.sh``: Shell wrapper that tracks peak memory and
>> +  execution time for ``ovn-northd`` and ``ovn-controller``.  By default
>> +  it reports peak virtual memory (VmPeak) from ``/proc/<pid>/status``,
>> +  which captures all allocated memory including pages not yet accessed.
>> +  Pass ``--valgrind`` to restart the tracked processes under Valgrind
>> +  Massif and report peak heap memory instead; this is more accurate but
>> +  10-50x slower.
>> +- ``ovn-benchmark.py``: Python script that populates the Northbound database
>> +  with a realistic ovn-kubernetes-style topology.
>> +
>> +The generated topology includes gateway routers, logical switches, NAT 
>> rules,
>> +ACLs, DHCP/DHCPv6, DNS, load balancers, QoS rules, address sets, port 
>> groups,
>> +static routes, and routing policies -- all with both IPv4 and IPv6 coverage.
>> +After creation, incremental add/modify/delete operations exercise the northd
>> +engine and flag unexpected recomputes.
>> +
>> +The benchmark must be run from inside the OVN sandbox.  Run
>> +``./ovn-benchmark.sh --help`` for the full list of options::
>> +
>> +    $ make sandbox
>> +    $ ./ovn-benchmark.sh
>> +
>> +By default, the topology is generated through many individual OVSDB
>> +transactions via ``ovn-benchmark.py``.
>> +
>> +.. note::
>> +
>> +   The ``-f`` option loads a Northbound database file via ``ovsdb-client
>> +   restore`` instead, which applies the entire database as a single
>> +   transaction.  This may yield different memory and timing results because
>> +   ``ovn-northd`` and ``ovn-controller`` process one large batch of changes
>> +   rather than reacting to each transaction incrementally.
>> +
>>  OVN Upgrade Testing
>>  ~~~~~~~~~~~~~~~~~~~
>>  
>> diff --git a/tutorial/automake.mk b/tutorial/automake.mk
>> index 631208639..fdea2449c 100644
>> --- a/tutorial/automake.mk
>> +++ b/tutorial/automake.mk
>> @@ -2,7 +2,9 @@ EXTRA_DIST += \
>>      tutorial/ovn-sandbox \
>>      tutorial/ovn-setup.sh \
>>      tutorial/ovn-lb-benchmark.sh \
>> -    tutorial/ovn-lb-benchmark.py
>> +    tutorial/ovn-lb-benchmark.py \
>> +    tutorial/ovn-benchmark.sh \
>> +    tutorial/ovn-benchmark.py
>>  sandbox: all
>>      cd $(srcdir)/tutorial && MAKE=$(MAKE) HAVE_OPENSSL=$(HAVE_OPENSSL) \
>>              ./ovn-sandbox -b $(abs_builddir) --ovs-src $(ovs_srcdir) 
>> --ovs-build $(ovs_builddir) $(SANDBOXFLAGS)
>> diff --git a/tutorial/ovn-benchmark.py b/tutorial/ovn-benchmark.py
>> new file mode 100755
>> index 000000000..d2d4bb9fd
>> --- /dev/null
>> +++ b/tutorial/ovn-benchmark.py
>> @@ -0,0 +1,1449 @@
>> +#!/usr/bin/env python3
>> +"""OVN memory and performance regression testing tool.
>> +
>> +Creates a broad OVN topology, then exercises incremental engine paths
>> +by adding, modifying, and deleting resources.  Designed to be run via
>> +ovn-benchmark.sh, which measures peak memory, per-phase timing, and
>> +checks for unexpected engine recomputes.  (Run ./ovn-benchmark.sh
>> +--help to see usage).
>> +
>> +Topology (simulates the default ovn-kubernetes topology):
>> +
>> +         lsp-0-*            lsp-1-*          (Workload ports)
>> +            |                  |
>> +         ls-0               ls-1             (Logical Switches)
>> +            |                  |
>> +         s2c-0/c2s-0        s2c-1/c2s-1
>> +              \\               /
>> +         +--- cluster (LR) ---+              (Cluster Router)
>> +                    |
>> +                sjc/rcj
>> +                    |
>> +         +----  join (LS)  ----+              (Join Switch)
>> +              /               \\
>> +         j2gw-0/gw2j-0     j2gw-1/gw2j-1
>> +            |                  |
>> +         gw-0               gw-1             (Gateway Routers)
>> +
>> +  Chassis binding (with batch size B, default n/10):
>> +    chassis-1: c2s-[0..B), gw-[0..B)    (matches sandbox system-id)
>> +    chassis-2: c2s-[B..2B), gw-[B..2B)
>> +    ...
>> +
>> +Each node creates a dual-stack gateway router + logical switch pair
>> +with:
>> +  - Dual-stack addressing (IPv4 10.x.x.x/24, IPv6 fd00:x::/64)
>> +  - NAT (SNAT/DNAT/DNAT_AND_SNAT), static routes, routing policies
>> +  - Configurable ports per switch with port security
>> +  - Security: Address sets, port groups, ACLs
>> +  - Services: DHCP, DHCPv6, DNS, load balancers
>> +  - QoS: Bandwidth limiting, DSCP marking
>> +
>> +Note: Uses explicit (non-templated) load balancers to maximize memory
>> +usage for regression testing. For templated LB testing, see
>> +ovn-lb-benchmark.py.
>> +"""
>> +
>> +import argparse
>> +import sys
>> +
>> +import ovs.db.idl
>> +import ovs.poller
>> +import ovs.vlog
>> +from ovs.db import error
>> +
>> +vlog = ovs.vlog.Vlog('ovn-benchmark')
>> +vlog.set_levels_from_string('console:warn')
>> +vlog.init(None)
>> +
>> +SCHEMA = '../ovn-nb.ovsschema'
>> +
>> +
>> +def die(msg):
>> +    sys.stderr.write(f'\nError: {msg}\n')
>> +    sys.exit(1)
>> +
>> +
>> +def ip_node(i):
>> +    """Convert node index to two IP octets, supporting up to 65535 nodes."""
>> +    return f'{i >> 8}.{i & 0xff}'
>> +
>> +
>> +def ip6_node(i):
>> +    """Convert node index to an IPv6 hextet for ULA addresses."""
>> +    return f'{i:x}'
>> +
>> +
>> +def create_address_sets(idl, n):
>> +    """Create address sets for security groups."""
>> +    vlog.info(f'Creating address sets: web_servers ({n} v4 + {n} v6), '
>> +              f'db_servers ({n} v4 + {n} v6), app_servers ({n} v4 + {n} 
>> v6), '
>> +              f'trusted_networks_v4, trusted_networks_v6')
>> +    txn = ovs.db.idl.Transaction(idl)
>> +
>> +    web_as = txn.insert(idl.tables['Address_Set'])
>> +    web_as.name = 'web_servers'
>> +    web_as.addresses = (
>> +        [f'10.{ip_node(i)}.10' for i in range(n)]
>> +        + [f'fd00:{ip6_node(i)}::a' for i in range(n)])
>> +
>> +    db_as = txn.insert(idl.tables['Address_Set'])
>> +    db_as.name = 'db_servers'
>> +    db_as.addresses = (
>> +        [f'10.{ip_node(i)}.20' for i in range(n)]
>> +        + [f'fd00:{ip6_node(i)}::14' for i in range(n)])
>> +
>> +    app_as = txn.insert(idl.tables['Address_Set'])
>> +    app_as.name = 'app_servers'
>> +    app_as.addresses = (
>> +        [f'10.{ip_node(i)}.30' for i in range(n)]
>> +        + [f'fd00:{ip6_node(i)}::1e' for i in range(n)])
>> +
>> +    trusted_as_v4 = txn.insert(idl.tables['Address_Set'])
>> +    trusted_as_v4.name = 'trusted_networks_v4'
>> +    trusted_as_v4.addresses = ['192.168.0.0/16', '172.16.0.0/12']
>> +
>> +    trusted_as_v6 = txn.insert(idl.tables['Address_Set'])
>> +    trusted_as_v6.name = 'trusted_networks_v6'
>> +    trusted_as_v6.addresses = ['fc00::/7']
>> +
>> +    if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
>> +        die(f'Failed to create address sets ({txn.get_error()})')
>> +
>> +
>> +def create_port_groups(idl):
>> +    """Create port groups for security group implementation."""
>> +    vlog.info('Creating port groups: web_tier, db_tier, app_tier')
>> +    txn = ovs.db.idl.Transaction(idl)
>> +
>> +    web_pg = txn.insert(idl.tables['Port_Group'])
>> +    web_pg.name = 'web_tier'
>> +
>> +    db_pg = txn.insert(idl.tables['Port_Group'])
>> +    db_pg.name = 'db_tier'
>> +
>> +    app_pg = txn.insert(idl.tables['Port_Group'])
>> +    app_pg.name = 'app_tier'
>> +
>> +    if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
>> +        die(f'Failed to create port groups ({txn.get_error()})')
>> +
>> +
>> +def create_dhcp_options(idl, n):
>> +    """Create DHCP options for each subnet."""
>> +    for i in range(n):
>> +        vlog.info(f'Creating DHCP options for node {i}: '
>> +                  f'10.{ip_node(i)}.0/24, fd00:{ip6_node(i)}::/64')
>> +        txn = ovs.db.idl.Transaction(idl)
>> +        dhcp_opts = txn.insert(idl.tables['DHCP_Options'])
>> +        dhcp_opts.cidr = f'10.{ip_node(i)}.0/24'
>> +        dhcp_opts.setkey('options', 'server_id', f'10.{ip_node(i)}.1')
>> +        dhcp_opts.setkey('options', 'server_mac', '00:00:00:00:00:01')
>> +        dhcp_opts.setkey('options', 'lease_time', '3600')
>> +        dhcp_opts.setkey('options', 'router', f'10.{ip_node(i)}.1')
>> +        dhcp_opts.setkey('options', 'dns_server', f'10.{ip_node(i)}.2')
>> +        dhcp_opts.setkey('options', 'domain_name', '"example.com"')
>> +        dhcp_opts.setkey('options', 'mtu', '1500')
>> +        dhcp_opts.setkey('external_ids', 'subnet', f'ls-{i}')
>> +
>> +        dhcpv6_opts = txn.insert(idl.tables['DHCP_Options'])
>> +        dhcpv6_opts.cidr = f'fd00:{ip6_node(i)}::/64'
>> +        dhcpv6_opts.setkey('options', 'server_id',
>> +                           '00:00:00:00:00:01')
>> +        dhcpv6_opts.setkey('options', 'dns_server',
>> +                           f'fd00:{ip6_node(i)}::2')
>> +        dhcpv6_opts.setkey('options', 'domain_search',
>> +                           '"example.com"')
>> +        dhcpv6_opts.setkey('external_ids', 'subnet',
>> +                           f'ls-{i}-v6')
>> +
>> +        if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
>> +            die(f'Failed to create DHCP options for node {i} '
>> +                f'({txn.get_error()})')
>> +
>> +
>> +def create_dhcp_assignments(idl, n):
>> +    """Assign DHCP options to logical switch ports."""
>> +    dhcp4 = {}
>> +    dhcp6 = {}
>> +    for row in idl.tables['DHCP_Options'].rows.values():
>> +        subnet = row.external_ids.get('subnet', '')
>> +        if subnet.endswith('-v6'):
>> +            dhcp6[subnet[:-3]] = row
>> +        else:
>> +            dhcp4[subnet] = row
>> +
>> +    for i in range(n):
>> +        vlog.info(f'Assigning DHCP options for node {i}: '
>> +                  f'ls-{i} ports -> 10.{ip_node(i)}.0/24 + '
>> +                  f'fd00:{ip6_node(i)}::/64')
>> +        txn = ovs.db.idl.Transaction(idl)
>> +        opts4 = dhcp4.get(f'ls-{i}')
>> +        opts6 = dhcp6.get(f'ls-{i}')
>> +        for row in idl.tables['Logical_Switch_Port'].rows.values():
>> +            if row.name.startswith(f'lsp-{i}-'):
>> +                if opts4:
>> +                    row.dhcpv4_options = opts4.uuid
>> +                if opts6:
>> +                    row.dhcpv6_options = opts6.uuid
>> +        if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
>> +            die(f'Failed to assign DHCP options for node {i} '
>> +                f'({txn.get_error()})')
>> +
>> +
>> +def create_qos_rules(idl, n, switches):
>> +    """Create QoS rules for bandwidth limiting and DSCP marking."""
>> +    for i in range(n):
>> +        vlog.info(f'Creating QoS rules for node {i}: '
>> +                  f'rate-limit on lsp-{i}-0, DSCP/mark on ls-{i}')
>> +        txn = ovs.db.idl.Transaction(idl)
>> +
>> +        ls = switches.get(f'ls-{i}')
>> +        if not ls:
>> +            die(f'Missing switch ls-{i}')
>> +
>> +        qos_bw = txn.insert(idl.tables['QoS'])
>> +        qos_bw.priority = 100
>> +        qos_bw.direction = 'to-lport'
>> +        qos_bw.match = f'inport == "lsp-{i}-0"'
>> +        qos_bw.setkey('bandwidth', 'rate', 1000)
>> +        qos_bw.setkey('bandwidth', 'burst', 100)
>> +        qos_bw.setkey('external_ids', 'type', 'rate-limit')
>> +        ls.addvalue('qos_rules', qos_bw.uuid)
>> +
>> +        qos_dscp = txn.insert(idl.tables['QoS'])
>> +        qos_dscp.priority = 200
>> +        qos_dscp.direction = 'from-lport'
>> +        qos_dscp.match = 'ip4 && tcp.dst == 22'
>> +        qos_dscp.setkey('action', 'dscp', 46)
>> +        qos_dscp.setkey('external_ids', 'type', 'dscp-marking')
>> +        ls.addvalue('qos_rules', qos_dscp.uuid)
>> +
>> +        qos_mark = txn.insert(idl.tables['QoS'])
>> +        qos_mark.priority = 150
>> +        qos_mark.direction = 'from-lport'
>> +        qos_mark.match = 'ip4 && udp'
>> +        qos_mark.setkey('action', 'mark', 1)
>> +        qos_mark.setkey('external_ids', 'type', 'packet-marking')
>> +        ls.addvalue('qos_rules', qos_mark.uuid)
>> +
>> +        qos_dscp_v6 = txn.insert(idl.tables['QoS'])
>> +        qos_dscp_v6.priority = 200
>> +        qos_dscp_v6.direction = 'from-lport'
>> +        qos_dscp_v6.match = 'ip6 && tcp.dst == 22'
>> +        qos_dscp_v6.setkey('action', 'dscp', 46)
>> +        qos_dscp_v6.setkey('external_ids', 'type',
>> +                           'dscp-marking-v6')
>> +        ls.addvalue('qos_rules', qos_dscp_v6.uuid)
>> +
>> +        qos_mark_v6 = txn.insert(idl.tables['QoS'])
>> +        qos_mark_v6.priority = 150
>> +        qos_mark_v6.direction = 'from-lport'
>> +        qos_mark_v6.match = 'ip6 && udp'
>> +        qos_mark_v6.setkey('action', 'mark', 1)
>> +        qos_mark_v6.setkey('external_ids', 'type',
>> +                           'packet-marking-v6')
>> +        ls.addvalue('qos_rules', qos_mark_v6.uuid)
>> +
>> +        if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
>> +            die(f'Failed to create QoS rules for node {i} 
>> ({txn.get_error()})')
>> +
>> +
>> +def create_acls_for_port_group(idl, port_groups, pg_name, allowed_ports):
>> +    """Create ACLs for a specific port group."""
>> +    txn = ovs.db.idl.Transaction(idl)
>> +
>> +    pg = port_groups.get(pg_name)
>> +    if not pg:
>> +        die(f'Missing port group {pg_name}')
>> +
>> +    acl_allow_est = txn.insert(idl.tables['ACL'])
>> +    acl_allow_est.priority = 1100
>> +    acl_allow_est.direction = 'to-lport'
>> +    acl_allow_est.match = 'ct.est && !ct.rel && !ct.new && !ct.inv'
>> +    acl_allow_est.action = 'allow-related'
>> +    pg.addvalue('acls', acl_allow_est.uuid)
>> +
>> +    acl_allow_rel = txn.insert(idl.tables['ACL'])
>> +    acl_allow_rel.priority = 1100
>> +    acl_allow_rel.direction = 'to-lport'
>> +    acl_allow_rel.match = 'ct.rel && !ct.est && !ct.new && !ct.inv'
>> +    acl_allow_rel.action = 'allow-related'
>> +    pg.addvalue('acls', acl_allow_rel.uuid)
>> +
>> +    for port in allowed_ports:
>> +        acl_new = txn.insert(idl.tables['ACL'])
>> +        acl_new.priority = 1050
>> +        acl_new.direction = 'to-lport'
>> +        acl_new.match = f'ct.new && tcp.dst == {port}'
>> +        acl_new.action = 'allow-related'
>> +        pg.addvalue('acls', acl_new.uuid)
>> +
>> +    acl_drop = txn.insert(idl.tables['ACL'])
>> +    acl_drop.priority = 1000
>> +    acl_drop.direction = 'to-lport'
>> +    acl_drop.match = f'outport == @{pg_name}'
>> +    acl_drop.action = 'drop'
>> +    pg.addvalue('acls', acl_drop.uuid)
>> +
>> +    acl_arp = txn.insert(idl.tables['ACL'])
>> +    acl_arp.priority = 1010
>> +    acl_arp.direction = 'to-lport'
>> +    acl_arp.match = 'arp || nd'
>> +    acl_arp.action = 'allow'
>> +    pg.addvalue('acls', acl_arp.uuid)
>> +
>> +    if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
>> +        die(f'Failed to create ACLs for {pg_name} ({txn.get_error()})')
>> +
>> +
>> +def create_acls(idl, port_groups):
>> +    """Create comprehensive ACLs for port groups.
>> +
>> +    ACL Priority Allocation:
>> +      2500-2999: Security enforcement (anti-spoofing, etc.)
>> +      2000-2499: Management access (SSH, ICMP, DHCP)
>> +      1000-1499: Port group ACLs (security groups)
>> +         1100: Connection tracking (established, related)
>> +         1050: New connection per-port rules
>> +         1010: ARP/ND allow
>> +         1000: Default drop
>> +
>> +    Switch ACLs (higher priority) can override port-group ACLs, with
>> +    security enforcement (anti-spoofing) taking highest priority.
>> +    """
>> +    vlog.info('Creating ACLs for port groups')
>> +    create_acls_for_port_group(idl, port_groups, 'web_tier', [80, 443])
>> +    create_acls_for_port_group(idl, port_groups, 'app_tier', [8080, 9000])
>> +    create_acls_for_port_group(idl, port_groups, 'db_tier', [5432, 3306])
>> +
>> +
>> +def _add_acl(txn, idl, ls, priority, direction, match, action,
>> +             description=None):
>> +    """Create an ACL and attach it to a logical switch."""
>> +    acl = txn.insert(idl.tables['ACL'])
>> +    acl.priority = priority
>> +    acl.direction = direction
>> +    acl.match = match
>> +    acl.action = action
>> +    if description:
>> +        acl.setkey('external_ids', 'description', description)
>> +    ls.addvalue('acls', acl.uuid)
>> +
>> +
>> +def create_switch_acls(idl, switch_name, node_id, switches):
>> +    """Create ACLs on a logical switch.
>> +
>> +    Creates switch-level ACLs for SSH, ICMP, anti-spoofing, and DHCP.
>> +    Anti-spoofing (priority 2500) prevents VMs from using IPs outside
>> +    their assigned subnet, blocking IP address spoofing attacks.
>> +    """
>> +    txn = ovs.db.idl.Transaction(idl)
>> +
>> +    ls = switches.get(switch_name)
>> +    if not ls:
>> +        die(f'Missing switch {switch_name}')
>> +
>> +    _add_acl(txn, idl, ls, 2000, 'from-lport',
>> +             'tcp.dst == 22 && ip4.src == 10.0.0.0/8',
>> +             'allow', 'Allow SSH from internal')
>> +    _add_acl(txn, idl, ls, 1500, 'from-lport',
>> +             'icmp4 || icmp6', 'allow')
>> +    _add_acl(txn, idl, ls, 2500, 'from-lport',
>> +             f'ip4.src != 10.{ip_node(node_id)}.0/24',
>> +             'drop', 'Anti-spoofing')
>> +    _add_acl(txn, idl, ls, 2000, 'from-lport',
>> +             'udp.src == 68 && udp.dst == 67', 'allow')
>> +
>> +    _add_acl(txn, idl, ls, 2000, 'from-lport',
>> +             'tcp.dst == 22 && ip6.src == fd00::/16',
>> +             'allow', 'Allow SSH from internal (IPv6)')
>> +    _add_acl(txn, idl, ls, 2500, 'from-lport',
>> +             f'ip6.src != fd00:{ip6_node(node_id)}::/64',
>> +             'drop', 'Anti-spoofing (IPv6)')
>> +    _add_acl(txn, idl, ls, 2000, 'from-lport',
>> +             'udp.src == 546 && udp.dst == 547', 'allow')
>> +
>> +    _add_acl(txn, idl, ls, 1800, 'to-lport',
>> +             'ip4.dst == $web_servers && tcp.dst == 80',
>> +             'allow-related', 'Allow HTTP to web servers')
>> +    _add_acl(txn, idl, ls, 1800, 'to-lport',
>> +             'ip4.dst == $db_servers && tcp.dst == 5432',
>> +             'allow-related', 'Allow Postgres to DB servers')
>> +    _add_acl(txn, idl, ls, 1800, 'to-lport',
>> +             'ip4.dst == $app_servers && tcp.dst == 8080',
>> +             'allow-related', 'Allow HTTP to app servers')
>> +
>> +    if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
>> +        die(f'Failed to create ACLs on switch {switch_name} '
>> +            f'({txn.get_error()})')
>> +
>> +
>> +def create_dns_records(idl, n, switches):
>> +    """Create DNS records in the NB database."""
>> +    for i in range(n):
>> +        vlog.info(f'Creating DNS records for node {i}: '
>> +                  f'web-{i}.example.com -> 10.{ip_node(i)}.10, '
>> +                  f'app-{i}.example.com -> 10.{ip_node(i)}.30, '
>> +                  f'db-{i}.example.com -> 10.{ip_node(i)}.20')
>> +        txn = ovs.db.idl.Transaction(idl)
>> +        dns = txn.insert(idl.tables['DNS'])
>> +        dns.setkey('records', f'web-{i}.example.com',
>> +                   f'10.{ip_node(i)}.10 '
>> +                   f'fd00:{ip6_node(i)}::a')
>> +        dns.setkey('records', f'app-{i}.example.com',
>> +                   f'10.{ip_node(i)}.30 '
>> +                   f'fd00:{ip6_node(i)}::1e')
>> +        dns.setkey('records', f'db-{i}.example.com',
>> +                   f'10.{ip_node(i)}.20 '
>> +                   f'fd00:{ip6_node(i)}::14')
>> +        dns.setkey('external_ids', 'zone', f'zone-{i}')
>> +
>> +        ls = switches.get(f'ls-{i}')
>> +        if not ls:
>> +            die(f'Missing switch ls-{i}')
>> +        ls.addvalue('dns_records', dns.uuid)
>> +
>> +        if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
>> +            die(f'Failed to create DNS records for node {i} '
>> +                f'({txn.get_error()})')
>> +
>> +
>> +def create_nat_rules(idl, n, routers):
>> +    """Create NAT rules on routers.
>> +
>> +    Creates SNAT, DNAT, and DNAT_AND_SNAT rules to exercise all NAT code 
>> paths.
>> +    """
>> +    for i in range(n):
>> +        vlog.info(f'Creating NAT rules on router gw-{i}: '
>> +                  f'snat 10.{ip_node(i)}.0/24 -> 192.{ip_node(i)}.1, '
>> +                  f'dnat .10 -> .10, dnat_and_snat .20 -> .20')
>> +        vlog.info(f'  IPv6: snat fd00:{ip6_node(i)}::/64 -> '
>> +                  f'fd01:{ip6_node(i)}::1, '
>> +                  f'dnat ::a -> ::a, dnat_and_snat ::14 -> ::14')
>> +        txn = ovs.db.idl.Transaction(idl)
>> +
>> +        lr = routers.get(f'gw-{i}')
>> +        if not lr:
>> +            die(f'Missing router gw-{i}')
>> +
>> +        nat_snat = txn.insert(idl.tables['NAT'])
>> +        nat_snat.type = 'snat'
>> +        nat_snat.logical_ip = f'10.{ip_node(i)}.0/24'
>> +        nat_snat.external_ip = f'192.{ip_node(i)}.1'
>> +        lr.addvalue('nat', nat_snat.uuid)
>> +
>> +        nat_dnat = txn.insert(idl.tables['NAT'])
>> +        nat_dnat.type = 'dnat'
>> +        nat_dnat.logical_ip = f'10.{ip_node(i)}.10'
>> +        nat_dnat.external_ip = f'192.{ip_node(i)}.10'
>> +        nat_dnat.setkey('external_ids', 'service', 'web')
>> +        lr.addvalue('nat', nat_dnat.uuid)
>> +
>> +        nat_dnat_and_snat = txn.insert(idl.tables['NAT'])
>> +        nat_dnat_and_snat.type = 'dnat_and_snat'
>> +        nat_dnat_and_snat.logical_ip = f'10.{ip_node(i)}.20'
>> +        nat_dnat_and_snat.external_ip = f'192.{ip_node(i)}.20'
>> +        nat_dnat_and_snat.setkey('external_ids', 'service', 'db')
>> +        lr.addvalue('nat', nat_dnat_and_snat.uuid)
>> +
>> +        nat_snat_v6 = txn.insert(idl.tables['NAT'])
>> +        nat_snat_v6.type = 'snat'
>> +        nat_snat_v6.logical_ip = f'fd00:{ip6_node(i)}::/64'
>> +        nat_snat_v6.external_ip = f'fd01:{ip6_node(i)}::1'
>> +        lr.addvalue('nat', nat_snat_v6.uuid)
>> +
>> +        nat_dnat_v6 = txn.insert(idl.tables['NAT'])
>> +        nat_dnat_v6.type = 'dnat'
>> +        nat_dnat_v6.logical_ip = f'fd00:{ip6_node(i)}::a'
>> +        nat_dnat_v6.external_ip = f'fd01:{ip6_node(i)}::a'
>> +        nat_dnat_v6.setkey('external_ids',
>> +                           'service', 'web-v6')
>> +        lr.addvalue('nat', nat_dnat_v6.uuid)
>> +
>> +        nat_ds_v6 = txn.insert(idl.tables['NAT'])
>> +        nat_ds_v6.type = 'dnat_and_snat'
>> +        nat_ds_v6.logical_ip = f'fd00:{ip6_node(i)}::14'
>> +        nat_ds_v6.external_ip = f'fd01:{ip6_node(i)}::14'
>> +        nat_ds_v6.setkey('external_ids',
>> +                         'service', 'db-v6')
>> +        lr.addvalue('nat', nat_ds_v6.uuid)
>> +
>> +        if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
>> +            die(f'Failed to create NAT rules for node {i} '
>> +                f'({txn.get_error()})')
>> +
>> +
>> +def create_static_routes(idl, n, routers):
>> +    """Create static routes on routers."""
>> +    for i in range(n):
>> +        vlog.info(f'Creating static routes on router gw-{i}: '
>> +                  f'default 0.0.0.0/0 -> 100.64.0.1, '
>> +                  f'172.{ip_node(i)}.0/24 -> 10.{ip_node(i)}.254, '
>> +                  f'192.0.2.0/24 -> discard')
>> +        vlog.info(f'  IPv6: ::/0 -> fd01::1, '
>> +                  f'fd02:{ip6_node(i)}::/64 -> fd00:{ip6_node(i)}::fe, '
>> +                  f'2001:db8::/32 -> discard')
>> +        txn = ovs.db.idl.Transaction(idl)
>> +
>> +        lr = routers.get(f'gw-{i}')
>> +        if not lr:
>> +            die(f'Missing router gw-{i}')
>> +
>> +        route_default = txn.insert(
>> +            idl.tables['Logical_Router_Static_Route'])
>> +        route_default.ip_prefix = '0.0.0.0/0'
>> +        route_default.nexthop = '100.64.0.1'
>> +        route_default.setkey('external_ids', 'type', 'default')
>> +        lr.addvalue('static_routes', route_default.uuid)
>> +
>> +        route_specific = txn.insert(
>> +            idl.tables['Logical_Router_Static_Route'])
>> +        route_specific.ip_prefix = f'172.{ip_node(i)}.0/24'
>> +        route_specific.nexthop = f'10.{ip_node(i)}.254'
>> +        route_specific.setkey('external_ids', 'type', 'specific')
>> +        lr.addvalue('static_routes', route_specific.uuid)
>> +
>> +        route_discard = txn.insert(
>> +            idl.tables['Logical_Router_Static_Route'])
>> +        route_discard.ip_prefix = '192.0.2.0/24'
>> +        route_discard.nexthop = 'discard'
>> +        route_discard.setkey('external_ids', 'type', 'blackhole')
>> +        lr.addvalue('static_routes', route_discard.uuid)
>> +
>> +        route_default_v6 = txn.insert(
>> +            idl.tables['Logical_Router_Static_Route'])
>> +        route_default_v6.ip_prefix = '::/0'
>> +        route_default_v6.nexthop = 'fd01::1'
>> +        route_default_v6.setkey('external_ids', 'type',
>> +                                'default-v6')
>> +        lr.addvalue('static_routes', route_default_v6.uuid)
>> +
>> +        route_specific_v6 = txn.insert(
>> +            idl.tables['Logical_Router_Static_Route'])
>> +        route_specific_v6.ip_prefix = (
>> +            f'fd02:{ip6_node(i)}::/64')
>> +        route_specific_v6.nexthop = (
>> +            f'fd00:{ip6_node(i)}::fe')
>> +        route_specific_v6.setkey('external_ids', 'type',
>> +                                 'specific-v6')
>> +        lr.addvalue('static_routes',
>> +                    route_specific_v6.uuid)
>> +
>> +        route_discard_v6 = txn.insert(
>> +            idl.tables['Logical_Router_Static_Route'])
>> +        route_discard_v6.ip_prefix = '2001:db8::/32'
>> +        route_discard_v6.nexthop = 'discard'
>> +        route_discard_v6.setkey('external_ids', 'type',
>> +                                'blackhole-v6')
>> +        lr.addvalue('static_routes',
>> +                    route_discard_v6.uuid)
>> +
>> +        if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
>> +            die(f'Failed to create static routes for node {i} '
>> +                f'({txn.get_error()})')
>> +
>> +
>> +def create_routing_policies(idl, n, routers):
>> +    """Create routing policies on routers."""
>> +    for i in range(n):
>> +        vlog.info(f'Creating routing policies on router gw-{i}: '
>> +                  f'reroute 10.{ip_node(i)}.0/24 -> '
>> +                  f'10.{ip_node((i + 1) % n)}.1, '
>> +                  f'allow $trusted_networks_v4')
>> +        vlog.info(f'  IPv6: reroute fd00:{ip6_node(i)}::/64 -> '
>> +                  f'fd00:{ip6_node((i + 1) % n)}::1, '
>> +                  f'allow $trusted_networks_v6')
>> +        txn = ovs.db.idl.Transaction(idl)
>> +
>> +        lr = routers.get(f'gw-{i}')
>> +        if not lr:
>> +            die(f'Missing router gw-{i}')
>> +
>> +        policy_reroute = txn.insert(idl.tables['Logical_Router_Policy'])
>> +        policy_reroute.priority = 100
>> +        policy_reroute.match = f'ip4.src == 10.{ip_node(i)}.0/24'
>> +        policy_reroute.action = 'reroute'
>> +        policy_reroute.nexthops = [f'10.{ip_node((i + 1) % n)}.1']
>> +        policy_reroute.setkey('external_ids', 'policy',
>> +                              'traffic-engineering')
>> +        lr.addvalue('policies', policy_reroute.uuid)
>> +
>> +        policy_allow = txn.insert(idl.tables['Logical_Router_Policy'])
>> +        policy_allow.priority = 50
>> +        policy_allow.match = 'ip4.dst == $trusted_networks_v4'
>> +        policy_allow.action = 'allow'
>> +        lr.addvalue('policies', policy_allow.uuid)
>> +
>> +        policy_reroute_v6 = txn.insert(
>> +            idl.tables['Logical_Router_Policy'])
>> +        policy_reroute_v6.priority = 100
>> +        policy_reroute_v6.match = (
>> +            f'ip6.src == fd00:{ip6_node(i)}::/64')
>> +        policy_reroute_v6.action = 'reroute'
>> +        policy_reroute_v6.nexthops = [
>> +            f'fd00:{ip6_node((i + 1) % n)}::1']
>> +        policy_reroute_v6.setkey(
>> +            'external_ids', 'policy',
>> +            'traffic-engineering-v6')
>> +        lr.addvalue('policies',
>> +                    policy_reroute_v6.uuid)
>> +
>> +        policy_allow_v6 = txn.insert(
>> +            idl.tables['Logical_Router_Policy'])
>> +        policy_allow_v6.priority = 50
>> +        policy_allow_v6.match = (
>> +            'ip6.dst == $trusted_networks_v6')
>> +        policy_allow_v6.action = 'allow'
>> +        lr.addvalue('policies', policy_allow_v6.uuid)
>> +
>> +        if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
>> +            die(f'Failed to create routing policies for node {i} '
>> +                f'({txn.get_error()})')
>> +
>> +
>> +def _add_ports(txn, idl, s, i, ports_per_switch):
>> +    for p in range(ports_per_switch):
>> +        lsp = txn.insert(idl.tables['Logical_Switch_Port'])
>> +        lsp.name = f'lsp-{i}-{p}'
>> +        # Offset by 10 to match the IP's +10 offset; wraps at 256 but
>> +        # the port cap (244) means this only matters at the boundary.
>> +        mac_byte = (p + 10) % 256
>> +        mac = (f'00:00:{i >> 8:02x}:{i & 0xff:02x}'
>> +               f':{p:02x}:{mac_byte:02x}')
>> +        ip = f'10.{ip_node(i)}.{10 + p}'
>> +        ip6 = f'fd00:{ip6_node(i)}::{10 + p:x}'
>> +        lsp.addresses = [f'{mac} {ip} {ip6}']
>> +        lsp.port_security = [f'{mac} {ip} {ip6}']
>> +        vlog.info(f'  lsp-{i}-{p}: {mac} {ip} {ip6}')
>> +        lsp.setkey('external_ids', 'vm-id', f'vm-{i}-{p}')
>> +
>> +        if p % 3 == 0:
>> +            lsp.setkey('external_ids', 'tier', 'web')
>> +        elif p % 3 == 1:
>> +            lsp.setkey('external_ids', 'tier', 'app')
>> +        else:
>> +            lsp.setkey('external_ids', 'tier', 'db')
>> +
>> +        s.addvalue('ports', lsp.uuid)
>> +
>> +
>> +def _add_node(txn, idl, i, chassis, cluster_rtr, join_sw, lbg,
>> +              ports_per_switch):
>> +    gwr = txn.insert(idl.tables['Logical_Router'])
>> +    gwr.name = f'gw-{i}'
>> +    gwr.addvalue('load_balancer_group', lbg.uuid)
>> +    gwr.setkey('options', 'chassis', chassis)
>> +
>> +    gwr2join = txn.insert(idl.tables['Logical_Router_Port'])
>> +    gwr2join.name = f'gw2j-{i}'
>> +    gwr2join.mac = f'00:00:01:{i >> 8:02x}:{i & 0xff:02x}:01'
>> +    gwr2join.networks = [f'100.64.{ip_node(i)}/16',
>> +                         f'fd01:{ip6_node(i)}::1/48']
>> +    gwr.addvalue('ports', gwr2join.uuid)
>> +
>> +    join2gwr = txn.insert(idl.tables['Logical_Switch_Port'])
>> +    join2gwr.name = f'j2gw-{i}'
>> +    join2gwr.type = 'router'
>> +    join2gwr.addresses = ['router']
>> +    join2gwr.setkey('options', 'router-port', gwr2join.name)
>> +    join_sw.addvalue('ports', join2gwr.uuid)
>> +
>> +    s = txn.insert(idl.tables['Logical_Switch'])
>> +    s.name = f'ls-{i}'
>> +    s.addvalue('load_balancer_group', lbg.uuid)
>> +    s.setkey('other_config', 'subnet', f'10.{ip_node(i)}.0/24')
>> +    s.setkey('other_config', 'mcast_snoop', 'true')
>> +
>> +    cluster2s = txn.insert(idl.tables['Logical_Router_Port'])
>> +    cluster2s.name = f'c2s-{i}'
>> +    cluster2s.mac = '00:00:00:00:00:01'
>> +    cluster2s.networks = [f'10.{ip_node(i)}.1/24',
>> +                          f'fd00:{ip6_node(i)}::1/64']
>> +    cluster_rtr.addvalue('ports', cluster2s.uuid)
>> +
>> +    gw_chassis = txn.insert(idl.tables['Gateway_Chassis'])
>> +    gw_chassis.name = f'{cluster2s.name}-{chassis}'
>> +    gw_chassis.chassis_name = chassis
>> +    gw_chassis.priority = 1
>> +    cluster2s.addvalue('gateway_chassis', gw_chassis.uuid)
>> +
>> +    s2cluster = txn.insert(idl.tables['Logical_Switch_Port'])
>> +    s2cluster.name = f's2c-{i}'
>> +    s2cluster.type = 'router'
>> +    s2cluster.addresses = ['router']
>> +    s2cluster.setkey('options', 'router-port', cluster2s.name)
>> +    s.addvalue('ports', s2cluster.uuid)
>> +
>> +    _add_ports(txn, idl, s, i, ports_per_switch)
>> +
>> +
>> +def create_topology(idl, n, ports_per_switch, batch_size):
>> +    """Create the basic topology with routers, switches, and ports."""
>> +    vlog.info('Creating topology')
>> +    txn = ovs.db.idl.Transaction(idl)
>> +    lbg = txn.insert(idl.tables['Load_Balancer_Group'])
>> +    lbg.name = 'lbg'
> 
> We never add load balancers through this LB group.  That's something
> that would be nice to test for too.  But I don't want to block this
> patch set any longer.  Would you mind sending a follow up for that?

Yup, I can address this and the asan/ubsan in follow-ups.

> 
>> +
>> +    vlog.info('Creating join switch')
>> +    join_sw = txn.insert(idl.tables['Logical_Switch'])
>> +    join_sw.name = 'join'
>> +
>> +    cluster_rtr = txn.insert(idl.tables['Logical_Router'])
>> +    cluster_rtr.name = 'cluster'
>> +
>> +    rcj = txn.insert(idl.tables['Logical_Router_Port'])
>> +    rcj.name = 'rcj'
>> +    rcj.mac = '00:00:00:00:00:01'
>> +    rcj.networks = ['100.64.0.1/16', 'fd01::1/48']
>> +    cluster_rtr.addvalue('ports', rcj.uuid)
>> +
>> +    sjc = txn.insert(idl.tables['Logical_Switch_Port'])
>> +    sjc.name = 'sjc'
>> +    sjc.type = 'router'
>> +    sjc.addresses = ['router']
>> +    sjc.setkey('options', 'router-port', 'rcj')
>> +    join_sw.addvalue('ports', sjc.uuid)
>> +
>> +    for i in range(n):
>> +        chassis = f'chassis-{i // batch_size + 1}'
>> +        vlog.info(f'Provisioning node {i}: gw-{i} + ls-{i}, '
>> +                  f'10.{ip_node(i)}.0/24, fd00:{ip6_node(i)}::/64, '
>> +                  f'{chassis}')
>> +        _add_node(txn, idl, i, chassis, cluster_rtr, join_sw, lbg,
>> +                  ports_per_switch)
>> +
>> +    if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
>> +        die(f'Failed to create topology ({txn.get_error()})')
>> +
>> +
>> +def create_port_group_assignments(idl, port_groups):
>> +    """Assign logical switch ports to port groups based on tier."""
>> +    tier_ports = {'web': [], 'app': [], 'db': []}
>> +
>> +    for row in idl.tables['Logical_Switch_Port'].rows.values():
>> +        tier = row.external_ids.get('tier')
>> +        if tier in tier_ports:
>> +            tier_ports[tier].append(row.uuid)
>> +
>> +    vlog.info(f'Assigning ports to port groups: '
>> +              f'{len(tier_ports["web"])} web, '
>> +              f'{len(tier_ports["app"])} app, '
>> +              f'{len(tier_ports["db"])} db')
>> +
>> +    txn = ovs.db.idl.Transaction(idl)
>> +    tier_to_group = {'web': 'web_tier', 'app': 'app_tier', 'db': 'db_tier'}
>> +    for tier, group_name in tier_to_group.items():
>> +        pg = port_groups.get(group_name)
>> +        if not pg:
>> +            die(f'Missing port group {group_name}')
>> +        for port_uuid in tier_ports[tier]:
>> +            pg.addvalue('ports', port_uuid)
>> +
>> +    if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
>> +        die(f'Failed to assign ports to groups ({txn.get_error()})')
>> +
>> +
>> +def create_explicit_lbs(idl, n, n_vips, n_backends, routers, switches):
>> +    """Create explicit (non-templated) load balancers.
>> +
>> +    Uses explicit (non-templated) LBs to maximize memory usage for
>> +    regression testing.
>> +    """
>> +    for i in range(n):
>> +        lr = routers.get(f'gw-{i}')
>> +        ls = switches.get(f'ls-{i}')
>> +        if not lr or not ls:
>> +            die(f'Missing router or switch for node {i}')
>> +        for j in range(n_vips):
>> +            txn = ovs.db.idl.Transaction(idl)
>> +            port = j + 1
>> +            vlog.info(f'Creating LB lb-{j}-{i}: '
>> +                      f'42.42.{ip_node(i)}:{port} -> '
>> +                      f'{n_backends} backends')
>> +            # Spread VIP index across two octets to stay within 0-249.
>> +            # Unlike ip_node() which uses >>8/&0xff for node addresses,
>> +            # this uses //250/%250 because VIP counts are small and 250
>> +            # avoids addresses that look like broadcast/network octets.
>> +            # Backend addresses depend only on j (not i), so every node
>> +            # shares the same backend set -- intentional, since we are
>> +            # testing memory, not realistic traffic distribution.
>> +            j1 = (j + 1) // 250
>> +            j2 = (j + 1) % 250
>> +
>> +            lb = txn.insert(idl.tables['Load_Balancer'])
>> +            lb.name = f'lb-{j}-{i}'
>> +            lb.setkey('vips', f'42.42.{ip_node(i)}:{port}',
>> +                      ','.join(f'42.{k}.{j1}.{j2}:{port}'
>> +                               for k in range(n_backends)))
>> +            lb.setkey(
>> +                'vips',
>> +                f'[fd42::{ip6_node(i)}:{j:x}]:{port}',
>> +                ','.join(f'[fd42:{k:x}::{j1:x}:{j2:x}]:{port}'
>> +                         for k in range(n_backends)))
>> +            lb.protocol = 'tcp'
>> +            lr.addvalue('load_balancer', lb.uuid)
>> +            ls.addvalue('load_balancer', lb.uuid)
>> +            if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
>> +                die(f'Failed to create LB ({txn.get_error()})')
>> +
>> +
>> +def connect_idl(remote):
>> +    """Connect to the NB database and return the IDL."""
>> +    schema_helper = ovs.db.idl.SchemaHelper(SCHEMA)
>> +    schema_helper.register_all()
>> +    idl = ovs.db.idl.Idl(remote, schema_helper, leader_only=False)
>> +
>> +    while idl.change_seqno == 0 and not idl.run():
>> +        poller = ovs.poller.Poller()
>> +        idl.wait(poller)
>> +        poller.block()
>> +    return idl
>> +
>> +
>> +def commit(txn, description):
>> +    """Commit a transaction, accepting both SUCCESS and UNCHANGED."""
>> +    status = txn.commit_block()
>> +    if status not in (ovs.db.idl.Transaction.SUCCESS,
>> +                      ovs.db.idl.Transaction.UNCHANGED):
>> +        die(f'{description} ({txn.get_error()})')
>> +
>> +
>> +def add_lsps(idl, n, ports_per_switch):
>> +    """Add one new port to ~10% of switches."""
>> +    if ports_per_switch < 1:
>> +        vlog.info('Skipping add_lsps: ports_per_switch is 0')
>> +        return
>> +    count = max(1, n // 10)
>> +    switches = {row.name: row
>> +                for row in idl.tables['Logical_Switch'].rows.values()}
>> +    txn = ovs.db.idl.Transaction(idl)
>> +    added = 0
>> +    p = ports_per_switch
>> +    for i in range(count):
>> +        ls = switches.get(f'ls-{i}')
>> +        if not ls:
>> +            continue
>> +        lsp = txn.insert(idl.tables['Logical_Switch_Port'])
>> +        lsp.name = f'lsp-{i}-{p}'
>> +        mac_byte = (p + 10) % 256
>> +        mac = (f'00:00:{i >> 8:02x}:{i & 0xff:02x}'
>> +               f':{p:02x}:{mac_byte:02x}')
>> +        ip = f'10.{ip_node(i)}.{10 + p}'
>> +        ip6 = f'fd00:{ip6_node(i)}::{10 + p:x}'
>> +        lsp.addresses = [f'{mac} {ip} {ip6}']
>> +        lsp.port_security = [f'{mac} {ip} {ip6}']
>> +        lsp.setkey('external_ids', 'vm-id', f'vm-{i}-{p}')
>> +        lsp.setkey('external_ids', 'tier', 'web')
>> +        ls.addvalue('ports', lsp.uuid)
>> +        added += 1
>> +    commit(txn, f'Failed to add {count} LSPs')
>> +    vlog.info(f'Added {added} LSPs '
>> +              f'(lsp-0-{p}..lsp-{count-1}-{p}, '
>> +              f'10.x.x.{10 + p})')
>> +
>> +
>> +def add_nat(idl, n):
>> +    """Add one DNAT rule to ~10% of routers."""
>> +    count = max(1, n // 10)
>> +    routers = {row.name: row
>> +               for row in idl.tables['Logical_Router'].rows.values()}
>> +    txn = ovs.db.idl.Transaction(idl)
>> +    added = 0
>> +    for i in range(count):
>> +        lr = routers.get(f'gw-{i}')
>> +        if not lr:
>> +            continue
>> +        nat = txn.insert(idl.tables['NAT'])
>> +        nat.type = 'dnat'
>> +        nat.logical_ip = f'10.{ip_node(i)}.30'
>> +        nat.external_ip = f'192.{ip_node(i)}.30'
>> +        nat.setkey('external_ids', 'service', 'bench')
>> +        lr.addvalue('nat', nat.uuid)
>> +        added += 1
>> +    commit(txn, f'Failed to add {count} NAT rules (service=bench)')
>> +    vlog.info(f'Added {added} DNAT rules '
>> +              f'(service=bench, .30 -> .30)')
>> +
>> +
>> +def add_routes(idl, n):
>> +    """Add one specific route to ~10% of routers."""
>> +    count = max(1, n // 10)
>> +    routers = {row.name: row
>> +               for row in idl.tables['Logical_Router'].rows.values()}
>> +    txn = ovs.db.idl.Transaction(idl)
>> +    added = 0
>> +    for i in range(count):
>> +        lr = routers.get(f'gw-{i}')
>> +        if not lr:
>> +            continue
>> +        route = txn.insert(
>> +            idl.tables['Logical_Router_Static_Route'])
>> +        route.ip_prefix = f'10.{ip_node(i)}.128/25'
>> +        route.nexthop = f'10.{ip_node(i)}.254'
>> +        route.setkey('external_ids', 'type', 'bench')
>> +        lr.addvalue('static_routes', route.uuid)
>> +        added += 1
>> +    commit(txn, f'Failed to add {count} static routes (type=bench)')
>> +    vlog.info(f'Added {added} static routes '
>> +              f'(10.x.x.128/25 -> 10.x.x.254)')
>> +
>> +
>> +def add_policies(idl, n):
>> +    """Add one allow policy to ~10% of routers."""
>> +    count = max(1, n // 10)
>> +    routers = {row.name: row
>> +               for row in idl.tables['Logical_Router'].rows.values()}
>> +    txn = ovs.db.idl.Transaction(idl)
>> +    added = 0
>> +    for i in range(count):
>> +        lr = routers.get(f'gw-{i}')
>> +        if not lr:
>> +            continue
>> +        policy = txn.insert(idl.tables['Logical_Router_Policy'])
>> +        policy.priority = 75
>> +        policy.match = f'ip4.src == 10.{ip_node(i)}.128/25'
>> +        policy.action = 'allow'
>> +        policy.setkey('external_ids', 'source', 'bench')
>> +        lr.addvalue('policies', policy.uuid)
>> +        added += 1
>> +    commit(txn, f'Failed to add {count} routing policies (source=bench)')
>> +    vlog.info(f'Added {added} routing policies '
>> +              f'(priority=75, allow 10.x.x.128/25)')
>> +
>> +
>> +def modify_nat(idl, n):
>> +    """Change external_ip on IPv4 SNAT rules for ~10% of routers."""
>> +    count = max(1, n // 10)
>> +    txn = ovs.db.idl.Transaction(idl)
>> +    modified = 0
>> +    for row in idl.tables['NAT'].rows.values():
>> +        if modified >= count:
>> +            break
>> +        if row.type == 'snat' and row.logical_ip.startswith('10.'):
>> +            prefix, last_octet = row.external_ip.rsplit('.', 1)
>> +            row.external_ip = f'{prefix}.{int(last_octet) + 1}'
>> +            modified += 1
>> +    commit(txn, f'Failed to modify {count} SNAT external_ips (.1 -> .2)')
>> +    vlog.info(f'Modified {modified} SNAT external_ips (.1 -> .2)')
>> +
>> +
>> +def modify_routes(idl, n):
>> +    """Change nexthop on IPv4 default routes for ~10% of routers."""
>> +    count = max(1, n // 10)
>> +    txn = ovs.db.idl.Transaction(idl)
>> +    modified = 0
>> +    for row in idl.tables[
>> +            'Logical_Router_Static_Route'].rows.values():
>> +        if modified >= count:
>> +            break
>> +        if (row.external_ids.get('type') == 'default'
>> +                and row.ip_prefix == '0.0.0.0/0'):
>> +            row.nexthop = '100.64.0.2'
>> +            modified += 1
>> +    commit(txn,
>> +           f'Failed to modify {count} default routes '
>> +           f'(nexthop 100.64.0.1 -> .2)')
>> +    vlog.info(f'Modified {modified} default route nexthops '
>> +              f'(100.64.0.1 -> 100.64.0.2)')
>> +
>> +
>> +def modify_lbs(idl, n):
>> +    """Change protocol on first LB for ~10% of nodes."""
>> +    count = max(1, n // 10)
>> +    targets = set(f'lb-0-{i}' for i in range(count))
>> +    txn = ovs.db.idl.Transaction(idl)
>> +    modified = 0
>> +    for row in idl.tables['Load_Balancer'].rows.values():
>> +        if row.name in targets:
>> +            row.protocol = 'udp' if row.protocol == 'tcp' else 'tcp'
>> +            modified += 1
>> +    commit(txn,
>> +           f'Failed to modify {count} LB protocols (tcp <-> udp)')
>> +    vlog.info(f'Modified {modified} LB protocols '
>> +              f'(lb-0-0..lb-0-{count-1}, tcp <-> udp)')
>> +
>> +
>> +def modify_addr_sets(idl, n):
>> +    """Add ~10% of n addresses to the web_servers address set."""
>> +    count = max(1, n // 10)
>> +    txn = ovs.db.idl.Transaction(idl)
>> +    added = 0
>> +    for row in idl.tables['Address_Set'].rows.values():
>> +        if row.name == 'web_servers':
>> +            for i in range(count):
>> +                row.addvalue('addresses', f'10.{ip_node(i)}.200')
>> +            added = count
>> +            break
>> +    commit(txn, f'Failed to modify web_servers address set '
>> +           f'(+= {count} addresses)')
>> +    vlog.info(f'Added {added} addresses to web_servers '
>> +              f'(10.0.0.200..10.{ip_node(count-1)}.200)')
>> +
>> +
>> +def modify_port_groups(idl, n):
>> +    """Add ~10% of app-tier ports to the web_tier port group."""
>> +    count = max(1, n // 10)
>> +    txn = ovs.db.idl.Transaction(idl)
>> +    added = 0
>> +    app_ports = []
>> +    for row in idl.tables['Logical_Switch_Port'].rows.values():
>> +        if row.external_ids.get('tier') == 'app':
>> +            app_ports.append(row)
>> +            if len(app_ports) >= count:
>> +                break
>> +    web_tier = None
>> +    for row in idl.tables['Port_Group'].rows.values():
>> +        if row.name == 'web_tier':
>> +            web_tier = row
>> +            break
>> +    if web_tier:
>> +        for port in app_ports:
>> +            web_tier.addvalue('ports', port.uuid)
>> +            added += 1
>> +    commit(txn, f'Failed to modify web_tier port group '
>> +           f'(+= {count} ports)')
>> +    vlog.info(f'Added {added} app-tier ports to web_tier')
>> +
>> +
>> +def modify_port_security(idl, n):
>> +    """Add an extra IP to port_security on ~10% of LSPs."""
>> +    count = max(1, n // 10)
>> +    txn = ovs.db.idl.Transaction(idl)
>> +    modified = 0
>> +    for row in idl.tables['Logical_Switch_Port'].rows.values():
>> +        if modified >= count:
>> +            break
>> +        if not row.name.startswith('lsp-') or not row.name.endswith('-1'):
>> +            continue
>> +        parts = row.name.split('-')
>> +        i = int(parts[1])
>> +        if i >= count:
>> +            continue
>> +        ps = list(row.port_security)
>> +        if ps:
>> +            ps[0] = ps[0] + f' 10.{ip_node(i)}.200'
>> +            row.port_security = ps
>> +            modified += 1
>> +    commit(txn,
>> +           f'Failed to modify {count} LSP port_security '
>> +           f'(+= 10.x.x.200)')
>> +    vlog.info(f'Modified {modified} LSP port_security '
>> +              f'(lsp-0-1..lsp-{count-1}-1, += 10.x.x.200)')
>> +
>> +
>> +def modify_dhcp(idl, n):
>> +    """Change lease_time on ~10% of DHCPv4 option sets."""
>> +    count = max(1, n // 10)
>> +    txn = ovs.db.idl.Transaction(idl)
>> +    modified = 0
>> +    for row in idl.tables['DHCP_Options'].rows.values():
>> +        if modified >= count:
>> +            break
>> +        subnet = row.external_ids.get('subnet', '')
>> +        if subnet.startswith('ls-') and not subnet.endswith('-v6'):
>> +            parts = subnet.split('-')
>> +            i = int(parts[1])
>> +            if i < count:
>> +                row.setkey('options', 'lease_time', '7200')
>> +                modified += 1
>> +    commit(txn,
>> +           f'Failed to modify {count} DHCP lease_time '
>> +           f'(3600 -> 7200)')
>> +    vlog.info(f'Modified {modified} DHCP lease_time '
>> +              f'(3600 -> 7200)')
>> +
>> +
>> +def modify_dns(idl, n):
>> +    """Add a DNS record to ~10% of nodes."""
>> +    count = max(1, n // 10)
>> +    txn = ovs.db.idl.Transaction(idl)
>> +    modified = 0
>> +    for row in idl.tables['DNS'].rows.values():
>> +        if modified >= count:
>> +            break
>> +        zone = row.external_ids.get('zone', '')
>> +        if zone.startswith('zone-'):
>> +            i = int(zone.split('-')[1])
>> +            if i < count:
>> +                row.setkey('records', f'bench-{i}.example.com',
>> +                           f'10.{ip_node(i)}.200')
>> +                modified += 1
>> +    commit(txn,
>> +           f'Failed to modify {count} DNS records '
>> +           f'(+= bench-x.example.com)')
>> +    vlog.info(f'Modified {modified} DNS records '
>> +              f'(+= bench-x.example.com -> 10.x.x.200)')
>> +
>> +
>> +def delete_acls(idl, n):
>> +    """Delete 'Allow SSH from internal' ACL from ~10% of switches."""
>> +    count = max(1, n // 10)
>> +    txn = ovs.db.idl.Transaction(idl)
>> +    deleted = 0
>> +    for row in list(idl.tables['ACL'].rows.values()):
>> +        if deleted >= count:
>> +            break
>> +        desc = row.external_ids.get('description', '')
>> +        if desc == 'Allow SSH from internal':
>> +            row.delete()
>> +            deleted += 1
>> +    commit(txn,
>> +           f'Failed to delete {count} ACLs '
>> +           f'(Allow SSH from internal)')
>> +    vlog.info(f'Deleted {deleted} ACLs '
>> +              f'(Allow SSH from internal)')
>> +
>> +
>> +def delete_policies(idl, n):
>> +    """Delete IPv4 allow policies from ~10% of routers."""
>> +    count = max(1, n // 10)
>> +    txn = ovs.db.idl.Transaction(idl)
>> +    deleted = 0
>> +    for row in list(
>> +            idl.tables['Logical_Router_Policy'].rows.values()):
>> +        if deleted >= count:
>> +            break
>> +        if (row.priority == 50
>> +                and 'trusted_networks_v4' in row.match):
>> +            row.delete()
>> +            deleted += 1
>> +    commit(txn,
>> +           f'Failed to delete {count} routing policies '
>> +           f'(priority=50, trusted_networks_v4)')
>> +    vlog.info(f'Deleted {deleted} routing policies '
>> +              f'(priority=50, trusted_networks_v4)')
>> +
>> +
>> +def delete_qos(idl, n):
>> +    """Delete rate-limit QoS rules from ~10% of switches."""
>> +    count = max(1, n // 10)
>> +    txn = ovs.db.idl.Transaction(idl)
>> +    deleted = 0
>> +    for row in list(idl.tables['QoS'].rows.values()):
>> +        if deleted >= count:
>> +            break
>> +        if row.external_ids.get('type') == 'rate-limit':
>> +            row.delete()
>> +            deleted += 1
>> +    commit(txn,
>> +           f'Failed to delete {count} QoS rules (type=rate-limit)')
>> +    vlog.info(f'Deleted {deleted} QoS rules (type=rate-limit)')
>> +
>> +
>> +def modify_lsps(idl, n):
>> +    """Modify addresses on ~10% of LSPs to exercise incremental 
>> processing."""
>> +    count = max(1, n // 10)
>> +    txn = ovs.db.idl.Transaction(idl)
>> +    modified = 0
>> +    for row in idl.tables['Logical_Switch_Port'].rows.values():
>> +        if modified >= count:
>> +            break
>> +        name = row.name
>> +        if not name.startswith('lsp-') or not name.endswith('-0'):
>> +            continue
>> +        parts = name.split('-')
>> +        i = int(parts[1])
>> +        if i >= count:
>> +            continue
>> +        mac = f'00:00:{i >> 8:02x}:{i & 0xff:02x}:00:0a'
>> +        ip = f'10.{ip_node(i)}.210'
>> +        ip6 = f'fd00:{ip6_node(i)}::d2'
>> +        row.addresses = [f'{mac} {ip} {ip6}']
>> +        row.port_security = [f'{mac} {ip} {ip6}']
>> +        modified += 1
>> +    commit(txn, f'Failed to modify {count} LSP addresses (.10 -> .210)')
>> +    vlog.info(f'Modified {modified} LSP addresses '
>> +              f'(lsp-0-0..lsp-{count-1}-0, .10 -> .210)')
>> +
>> +
>> +def modify_acls(idl, n):
>> +    """Modify anti-spoofing ACLs on ~10% of switches."""
>> +    count = max(1, n // 10)
>> +    txn = ovs.db.idl.Transaction(idl)
>> +    modified = 0
>> +    for row in idl.tables['ACL'].rows.values():
>> +        if modified >= count:
>> +            break
>> +        desc = row.external_ids.get('description', '')
>> +        if desc == 'Anti-spoofing' and '/24' in row.match:
>> +            row.match = row.match.replace('/24', '/23')
>> +            modified += 1
>> +    commit(txn, f'Failed to modify {count} anti-spoofing ACLs (/24 -> /23)')
>> +    vlog.info(f'Modified {modified} anti-spoofing ACLs (/24 -> /23)')
>> +
>> +
>> +def delete_lsps(idl, n, ports_per_switch):
>> +    """Delete the last port from ~10% of switches."""
>> +    if ports_per_switch < 1:
>> +        vlog.info('Skipping delete_lsps: no ports to delete')
>> +        return
>> +    count = max(1, n // 10)
>> +    last_port = ports_per_switch - 1
>> +    targets = set(f'lsp-{i}-{last_port}' for i in range(count))
>> +    txn = ovs.db.idl.Transaction(idl)
>> +    deleted = 0
>> +    for row in list(idl.tables['Logical_Switch_Port'].rows.values()):
>> +        if row.name in targets:
>> +            row.delete()
>> +            deleted += 1
>> +    commit(txn, f'Failed to delete {count} LSPs '
>> +           f'(lsp-0-{last_port}..lsp-{count-1}-{last_port})')
>> +    vlog.info(f'Deleted {deleted} LSPs '
>> +              f'(lsp-0-{last_port}..lsp-{count-1}-{last_port})')
>> +
>> +
>> +def delete_nat(idl, n):
>> +    """Delete IPv4 DNAT rules from ~10% of routers."""
>> +    count = max(1, n // 10)
>> +    txn = ovs.db.idl.Transaction(idl)
>> +    deleted = 0
>> +    for row in list(idl.tables['NAT'].rows.values()):
>> +        if deleted >= count:
>> +            break
>> +        if (row.type == 'dnat'
>> +                and row.external_ids.get('service') == 'web'):
>> +            row.delete()
>> +            deleted += 1
>> +    commit(txn, f'Failed to delete {count} IPv4 DNAT rules (service=web)')
>> +    vlog.info(f'Deleted {deleted} IPv4 DNAT rules (service=web)')
>> +
>> +
>> +def delete_routes(idl, n):
>> +    """Delete IPv4 specific routes from ~10% of routers."""
>> +    count = max(1, n // 10)
>> +    txn = ovs.db.idl.Transaction(idl)
>> +    deleted = 0
>> +    for row in list(
>> +            idl.tables['Logical_Router_Static_Route'].rows.values()):
>> +        if deleted >= count:
>> +            break
>> +        if row.external_ids.get('type') == 'specific':
>> +            row.delete()
>> +            deleted += 1
>> +    commit(txn, f'Failed to delete {count} IPv4 specific routes')
>> +    vlog.info(f'Deleted {deleted} IPv4 specific routes')
>> +
>> +
>> +def delete_lbs(idl, n):
>> +    """Delete one load balancer from ~10% of nodes."""
>> +    count = max(1, n // 10)
>> +    targets = set(f'lb-0-{i}' for i in range(count))
>> +    txn = ovs.db.idl.Transaction(idl)
>> +    deleted = 0
>> +    for row in list(idl.tables['Load_Balancer'].rows.values()):
>> +        if row.name in targets:
>> +            row.delete()
>> +            deleted += 1
>> +    commit(txn, f'Failed to delete {count} load balancers '
>> +           f'(lb-0-0..lb-0-{count-1})')
>> +    vlog.info(f'Deleted {deleted} load balancers '
>> +              f'(lb-0-0..lb-0-{count-1})')
>> +
>> +
>> +def run(remote, n, n_vips, n_backends, ports_per_switch, batch_size):
>> +    """Main execution function."""
>> +    idl = connect_idl(remote)
>> +
>> +    # Check if database is clean before proceeding
>> +    if (len(idl.tables['Load_Balancer_Group'].rows) > 0 or
>> +        len(idl.tables['Logical_Switch'].rows) > 0 or
>> +        len(idl.tables['Logical_Router'].rows) > 0):
>> +        die('Database is not empty. Please restart the sandbox or clear the 
>> '
>> +            'database before running this script.')
>> +
>> +    create_topology(idl, n, ports_per_switch, batch_size)
>> +
>> +    # Build lookup dictionaries for O(1) access to switches and routers
>> +    switches = {row.name: row
>> +                for row in idl.tables['Logical_Switch'].rows.values()}
>> +    routers = {row.name: row
>> +               for row in idl.tables['Logical_Router'].rows.values()}
>> +
>> +    create_address_sets(idl, n)
>> +    create_port_groups(idl)
>> +    port_groups = {row.name: row
>> +                   for row in idl.tables['Port_Group'].rows.values()}
>> +    create_port_group_assignments(idl, port_groups)
>> +    create_dhcp_options(idl, n)
>> +    create_dhcp_assignments(idl, n)
>> +    create_dns_records(idl, n, switches)
>> +    create_nat_rules(idl, n, routers)
>> +    create_static_routes(idl, n, routers)
>> +    create_acls(idl, port_groups)
>> +    for i in range(n):
>> +        create_switch_acls(idl, f'ls-{i}', i, switches)
>> +    create_qos_rules(idl, n, switches)
>> +    create_routing_policies(idl, n, routers)
>> +    create_explicit_lbs(idl, n, n_vips, n_backends, routers, switches)
>> +
>> +
>> +PHASES = [
>> +    'create',
>> +    'add-lsps', 'add-nat', 'add-routes', 'add-policies',
>> +    'modify-lsps', 'modify-acls', 'modify-nat', 'modify-routes',
>> +    'modify-lbs', 'modify-addr-sets', 'modify-pg', 'modify-port-sec',
>> +    'modify-dhcp', 'modify-dns',
>> +    'delete-lsps', 'delete-nat', 'delete-routes', 'delete-lbs',
>> +    'delete-acls', 'delete-policies', 'delete-qos',
>> +]
>> +
>> +
>> +def main():
>> +    """Parse arguments and run the benchmark."""
>> +    parser = argparse.ArgumentParser(
>> +        description='OVN benchmark: create topology, then exercise '
>> +                    'incremental engine with add/modify/delete operations'
>> +    )
>> +    parser.add_argument(
>> +        'phase', nargs='?', default='create', choices=PHASES,
>> +        help='Benchmark phase to run (default: create)',
>> +    )
>> +    parser.add_argument(
>> +        '-r', '--remote', required=True, help='NB connection string'
>> +    )
>> +    parser.add_argument(
>> +        '-n', '--nodes', type=int, required=True, help='Number of nodes'
>> +    )
>> +    parser.add_argument(
>> +        '-p',
>> +        '--ports-per-switch',
>> +        type=int,
>> +        default=9,
>> +        help='Number of logical switch ports per switch (default: 9, '
>> +             'provides 3 ports per tier)',
>> +    )
>> +    parser.add_argument(
>> +        '-v', '--vips', type=int, default=5,
>> +        help='Number of LB VIPs per node (default: 5)'
>> +    )
>> +    parser.add_argument(
>> +        '-B',
>> +        '--backends',
>> +        type=int,
>> +        default=5,
>> +        help='Number backends per VIP (default: 5)',
>> +    )
>> +    parser.add_argument(
>> +        '-b',
>> +        '--batch-size',
>> +        type=int,
>> +        default=0,
>> +        help='Nodes per chassis (default: n/10)',
>> +    )
>> +    parser.add_argument(
>> +        '-d', '--debug',
>> +        action='store_true',
>> +        help='Enable debug output (show info messages)',
>> +    )
>> +    args = parser.parse_args()
>> +
>> +    if args.batch_size <= 0:
>> +        args.batch_size = max(1, args.nodes // 10)
>> +
>> +    if args.nodes < 1:
>> +        die('node count must be at least 1')
>> +    # Node index is split across two IP octets via ip_node(), so the
>> +    # maximum is 2^16 - 1.
>> +    if args.nodes > 65535:
>> +        die('maximum supported node count is 65535')
>> +
>> +    if args.ports_per_switch < 0:
>> +        die('ports per switch must not be negative')
>> +    # Port IPs start at 10.x.y.10 (see _add_ports), so the highest
>> +    # address is 10.x.y.(10 + ports_per_switch - 1).  Cap at 245 to
>> +    # keep that <= 254 (valid IPv4 octet).
>> +    if args.ports_per_switch > 245:
>> +        die('maximum supported ports per switch is 245')
>> +
>> +    if args.vips < 0:
>> +        die('VIP count must not be negative')
>> +    # VIP index is split via (j+1)//250 and (j+1)%250; the quotient
>> +    # must fit in one octet, so max VIPs = 250 * 256 - 1 = 63999.
>> +    if args.vips > 63999:
>> +        die('maximum supported VIPs is 63999')
>> +
>> +    if args.backends < 0:
>> +        die('backend count must not be negative')
>> +    # Backend addresses use 42.{k}.j1.j2; k must fit in one octet.
>> +    if args.backends > 255:
>> +        die('maximum supported backends is 255')
>> +
>> +    if args.debug:
>> +        vlog.set_levels_from_string('console:info')
>> +
>> +    if args.phase == 'create':
>> +        n_chassis = ((args.nodes + args.batch_size - 1)
>> +                     // args.batch_size)
>> +        print(f'\n=== OVN Benchmark Configuration ===')
>> +        print(f'Nodes:                        {args.nodes}'
>> +              f' ({args.nodes} routers + {args.nodes} switches)')
>> +        print(f'Ports per switch:             {args.ports_per_switch} '
>> +              f'({args.ports_per_switch * args.nodes} total ports)')
>> +        print(f'Load balancer VIPs per node:  {args.vips} '
>> +              f'({args.vips * args.nodes} total VIPs)')
>> +        print(f'Backends per VIP:             {args.backends}')
>> +        print(f'Total load balancers:         '
>> +              f'{args.nodes * args.vips}')
>> +        print(f'Nodes per chassis (batch):    '
>> +              f'{args.batch_size} '
>> +              f'({n_chassis} chassis)')
>> +        print(f'Debug logging:                '
>> +              f'{"enabled" if args.debug else "disabled"}')
>> +        print('===================================\n')
>> +
>> +        run(args.remote, args.nodes, args.vips, args.backends,
>> +            args.ports_per_switch, args.batch_size)
>> +    else:
>> +        idl = connect_idl(args.remote)
>> +        n = args.nodes
>> +        if args.phase == 'add-lsps':
>> +            add_lsps(idl, n, args.ports_per_switch)
>> +        elif args.phase == 'add-nat':
>> +            add_nat(idl, n)
>> +        elif args.phase == 'add-routes':
>> +            add_routes(idl, n)
>> +        elif args.phase == 'add-policies':
>> +            add_policies(idl, n)
>> +        elif args.phase == 'modify-lsps':
>> +            modify_lsps(idl, n)
>> +        elif args.phase == 'modify-acls':
>> +            modify_acls(idl, n)
>> +        elif args.phase == 'modify-nat':
>> +            modify_nat(idl, n)
>> +        elif args.phase == 'modify-routes':
>> +            modify_routes(idl, n)
>> +        elif args.phase == 'modify-lbs':
>> +            modify_lbs(idl, n)
>> +        elif args.phase == 'modify-addr-sets':
>> +            modify_addr_sets(idl, n)
>> +        elif args.phase == 'modify-pg':
>> +            modify_port_groups(idl, n)
>> +        elif args.phase == 'modify-port-sec':
>> +            modify_port_security(idl, n)
>> +        elif args.phase == 'modify-dhcp':
>> +            modify_dhcp(idl, n)
>> +        elif args.phase == 'modify-dns':
>> +            modify_dns(idl, n)
>> +        elif args.phase == 'delete-lsps':
>> +            delete_lsps(idl, n, args.ports_per_switch)
>> +        elif args.phase == 'delete-nat':
>> +            delete_nat(idl, n)
>> +        elif args.phase == 'delete-routes':
>> +            delete_routes(idl, n)
>> +        elif args.phase == 'delete-lbs':
>> +            delete_lbs(idl, n)
>> +        elif args.phase == 'delete-acls':
>> +            delete_acls(idl, n)
>> +        elif args.phase == 'delete-policies':
>> +            delete_policies(idl, n)
>> +        elif args.phase == 'delete-qos':
>> +            delete_qos(idl, n)
>> +
>> +
>> +if __name__ == '__main__':
>> +    try:
>> +        main()
>> +    except error.Error as e:
>> +        sys.stderr.write(f'{e}\n')
>> +        sys.exit(1)
>> diff --git a/tutorial/ovn-benchmark.sh b/tutorial/ovn-benchmark.sh
>> new file mode 100755
>> index 000000000..e68ef6a4e
>> --- /dev/null
>> +++ b/tutorial/ovn-benchmark.sh
>> @@ -0,0 +1,561 @@
>> +#!/bin/bash
>> +
>> +DEFAULT_NODES=200
>> +
>> +PROCESS_NAME=()
>> +FILE_NAME=""
>> +NODES=""
>> +PROCESS_PIDS=()
>> +FINAL_PEAK_KB=()
>> +DEBUG=false
>> +BATCH_SIZE=""
>> +VALGRIND_MODE=false
>> +VALGRIND_PIDS=()
>> +MASSIF_FILES=()
>> +BENCHMARK_TMPDIR=""
>> +
>> +on_interrupt() {
>> +    echo ""
>> +    if [ "$VALGRIND_MODE" = true ]; then
>> +        echo "Exiting benchmark. Restarting tracked sandbox processes..."
>> +    fi
>> +    exit 1
>> +}
>> +trap on_interrupt INT
>> +
>> +# In valgrind mode, the original daemons are killed and replaced with
>> +# valgrind-wrapped copies.  This cleanup ensures they are restored on
>> +# both normal exit and Ctrl+C so the sandbox is not left broken.
>> +cleanup() {
>> +    # Stop any valgrind processes still running.
>> +    for vpid in "${VALGRIND_PIDS[@]}"; do
>> +        kill "$vpid" 2>/dev/null
>> +    done
>> +
>> +    if [ -n "$BENCHMARK_TMPDIR" ]; then
>> +        deadline=$(($(date +%s) + 30))
>> +        for vpid in "${VALGRIND_PIDS[@]}"; do
>> +            while [ -e "/proc/$vpid" ]; do
>> +                if [ "$(date +%s)" -ge "$deadline" ]; then
>> +                    echo "Warning: valgrind PID $vpid" \
>> +                         "did not exit; killing"
>> +                    kill -9 "$vpid" 2>/dev/null
> 
> Nit: They're the same thing but I prefer "kill -KILL".
> 
>> +                    break
>> +                fi
>> +                sleep 0.1
>> +            done
>> +        done
>> +
>> +        # Restart each daemon from the cmdline/cwd saved before we
>> +        # killed it, but only if it is not already running.
>> +        for i in "${!PROCESS_NAME[@]}"; do
>> +            pn="${PROCESS_NAME[$i]}"
>> +            if [ -f "$BENCHMARK_TMPDIR/cmdline.$pn" ] \
>> +               && ! pgrep -x "$pn" >/dev/null 2>&1; then
>> +                restart_cwd=$(cat "$BENCHMARK_TMPDIR/cwd.$pn")
>> +                mapfile -d '' restart_args < "$BENCHMARK_TMPDIR/cmdline.$pn"
>> +                # Drop trailing empty element from /proc/*/cmdline's final 
>> NUL.
>> +                if [ ${#restart_args[@]} -gt 0 ] \
>> +                   && [ -z "${restart_args[-1]}" ]; then
>> +                    unset 'restart_args[-1]'
>> +                fi
>> +                (cd "$restart_cwd" && "${restart_args[@]}" >/dev/null 2>&1)
>> +            fi
>> +        done
>> +
>> +        rm -rf "$BENCHMARK_TMPDIR"
>> +    fi
>> +}
>> +trap cleanup EXIT
>> +
>> +while [[ $# -gt 0 ]]; do
>> +    case "$1" in
>> +        -h|--help|--usage)
>> +            echo "Usage: $0 [OPTIONS] [NODES] [PROCESS...]"
>> +            echo ""
>> +            echo "Arguments:"
>> +            echo "  NODES         Number of nodes to create" \
>> +                 "(default: $DEFAULT_NODES)"
>> +            echo "  PROCESS       Process(es) to track:" \
>> +                 "ovn-northd, ovn-controller"
>> +            echo "                (default: both)"
>> +            echo ""
>> +            echo "Options:"
>> +            echo "  -f, --file FILE    Load NB database from file"
>> +            echo "  -b, --batch-size N Nodes per chassis" \
>> +                 "(default: NODES/10)"
>> +            echo "  -v, --valgrind     Track heap with Valgrind" \
>> +                 "Massif (slower, accurate)"
>> +            echo "  -d, --debug           Enable debug output"
>> +            echo "  -h, --help            Show this help message"
>> +            echo ""
>> +            echo "Memory tracking:"
>> +            echo "  Default: peak virtual memory (VmPeak) from"
>> +            echo "  /proc/<pid>/status."
>> +            echo "  --valgrind: peak heap via Valgrind Massif."
>> +            echo "  Expect 10-50x slowdown; 500 nodes or fewer"
>> +            echo "  recommended."
>> +            echo ""
>> +            echo "Examples:"
>> +            echo "  $0                      # 200 nodes, track both 
>> processes"
>> +            echo "  $0 50                   # 50 nodes"
>> +            echo "  $0 50 ovn-northd        # 50 nodes, track only 
>> ovn-northd"
>> +            echo "  $0 --valgrind           # Use Valgrind Massif, not 
>> VmPeak"
>> +            echo "  $0 --file ovnnb_db.db   # Load from file"
>> +            echo "  $0 --debug 20           # 20 nodes with debug output"
>> +            echo "  $0 50 -b 10             # 50 nodes, 10 per chassis"
>> +            echo ""
>> +            echo "Note: if only tracking one process, # of nodes is 
>> required"
>> +            exit 0
>> +            ;;
>> +        -b|--batch-size)
>> +            if [ -z "$2" ]; then
>> +                echo "Error: $1 requires an argument"
>> +                exit 1
>> +            fi
>> +            BATCH_SIZE="$2"
>> +            shift 2
>> +            ;;
>> +        -v|--valgrind)
>> +            VALGRIND_MODE=true
>> +            shift
>> +            ;;
>> +        -d|--debug)
>> +            DEBUG=true
>> +            shift
>> +            ;;
>> +        -f|--file)
>> +            if [ -z "$2" ]; then
>> +                echo "Error: $1 requires an argument"
>> +                exit 1
>> +            fi
>> +            FILE_NAME="$2"
>> +            shift 2
>> +            ;;
>> +        -*)
>> +            echo "Unknown option: $1"
>> +            exit 1
>> +            ;;
>> +        *)
>> +            if [ -z "$NODES" ]; then
>> +                NODES="$1"
>> +            else
>> +                # Normalize process names: accept both "northd" and
>> +                # "ovn-northd".
>> +                case "$1" in
>> +                    northd)
>> +                        PROCESS_NAME+=("ovn-northd")
>> +                        ;;
>> +                    controller)
>> +                        PROCESS_NAME+=("ovn-controller")
>> +                        ;;
>> +                    *)
>> +                        PROCESS_NAME+=("$1")
>> +                        ;;
>> +                esac
>> +            fi
>> +            shift
>> +            ;;
>> +    esac
>> +done
>> +
>> +# Must be run from inside the sandbox (tutorial/sandbox/).
>> +if [ ! -d "$PWD/sandbox" ]; then
>> +    echo "Error: must be run from inside the OVN sandbox."
>> +    echo "Start one with: make sandbox"
>> +    exit 1
>> +fi
>> +
>> +# Apply defaults if not set by user.
>> +NODES=${NODES:-$DEFAULT_NODES}
>> +
>> +if [ -z "$BATCH_SIZE" ]; then
>> +    BATCH_SIZE=$((NODES / 10))
>> +fi
>> +if [ "$BATCH_SIZE" -lt 1 ]; then
>> +    BATCH_SIZE=1
>> +fi
>> +
>> +# Track both processes if not specified.
>> +if [ ${#PROCESS_NAME[@]} -eq 0 ]; then
>> +    PROCESS_NAME=("ovn-controller" "ovn-northd")
>> +fi
>> +
>> +if [ "$DEBUG" = true ]; then
>> +    echo "Nodes:       $NODES"
>> +    echo "Batch size:  $BATCH_SIZE"
>> +    echo "Processes:   ${PROCESS_NAME[*]}"
>> +    echo "File:        ${FILE_NAME:-None}"
>> +fi
>> +
>> +PID_ERRORS=false
>> +for pn in "${PROCESS_NAME[@]}"; do
>> +    all_pids=$(pgrep -x "$pn")
>> +    if [ -z "$all_pids" ]; then
>> +        echo "Error: Could not find process matching '$pn'"
>> +        PID_ERRORS=true
>> +        continue
>> +    fi
>> +
>> +    if [ "$(echo "$all_pids" | wc -l)" -gt 1 ]; then
>> +        echo "Error: Multiple $pn processes found" \
>> +             "(PIDs: $(echo $all_pids | tr '\n' ' '))"
>> +        PID_ERRORS=true
>> +        continue
>> +    fi
>> +
>> +    PROCESS_PIDS+=("$all_pids")
>> +done
>> +
>> +if [ "$PID_ERRORS" = true ]; then
>> +    echo "Kill stale processes or ensure only one sandbox is running."
>> +    exit 1
>> +fi
>> +
>> +if [ "$DEBUG" = true ]; then
>> +    for i in "${!PROCESS_NAME[@]}"; do
>> +        echo "Tracking memory for ${PROCESS_NAME[$i]}" \
>> +             "(PID: ${PROCESS_PIDS[$i]})"
>> +    done
>> +fi
>> +
>> +if [ "$VALGRIND_MODE" = true ]; then
>> +    if ! command -v valgrind >/dev/null 2>&1; then
>> +        echo "Error: valgrind is not installed or not in PATH"
>> +        exit 1
>> +    fi
>> +
>> +    if [ "$NODES" -gt 500 ]; then
>> +        echo "Warning: valgrind mode with $NODES nodes may be very slow." \
>> +             "Consider using 500 or fewer nodes for heap profiling."
>> +    fi
>> +
>> +    BENCHMARK_TMPDIR=$(mktemp -d)
>> +    echo "Restarting processes under Valgrind Massif..."
>> +    echo "(Expect 10-50x slowdown)"
>> +
>> +    for i in "${!PROCESS_NAME[@]}"; do
>> +        pn="${PROCESS_NAME[$i]}"
>> +        pid="${PROCESS_PIDS[$i]}"
>> +
>> +        # Read the original command line so we can restart the daemon later.
>> +        # /proc/<pid>/cmdline is NUL-separated; the trailing NUL produces an
>> +        # empty final element which must be dropped.
>> +        mapfile -d '' orig_args < /proc/$pid/cmdline
>> +        if [ ${#orig_args[@]} -gt 0 ] \
>> +           && [ -z "${orig_args[-1]}" ]; then
>> +            unset 'orig_args[-1]'
>> +        fi
>> +
>> +        proc_cwd=$(readlink /proc/$pid/cwd)
>> +        massif_file="$BENCHMARK_TMPDIR/massif.$pn.out"
>> +        MASSIF_FILES+=("$massif_file")
>> +
>> +        # Save original cmdline and cwd so we can restart after valgrind.
>> +        cp /proc/$pid/cmdline "$BENCHMARK_TMPDIR/cmdline.$pn"
>> +        echo "$proc_cwd" > "$BENCHMARK_TMPDIR/cwd.$pn"
>> +
>> +        # Valgrind runs the process in the foreground, so strip args
>> +        # that assume daemonized execution:
>> +        #   --detach    : can't daemonize under valgrind
>> +        #   --no-chdir  : only meaningful with --detach
>> +        #   --pidfile   : valgrind doesn't write one (OVS uses
>> +        #                 optional_argument so bare --pidfile never
>> +        #                 takes a separate arg; only --pidfile=FILE)
>> +        #   --monitor   : would fork a monitor child under valgrind,
>> +        #                 causing two processes to race on massif output
>> +        filtered_args=()
>> +        for arg in "${orig_args[@]}"; do
>> +            case "$arg" in
>> +                --detach|--no-chdir|--monitor) ;;
>> +                --pidfile|--pid-file) ;;
>> +                --pidfile=*|--pid-file=*) ;;
>> +                *) filtered_args+=("$arg") ;;
>> +            esac
>> +        done
>> +
>> +        if [ "$DEBUG" = true ]; then
>> +            echo "Stopping $pn (PID $pid)..."
>> +        fi
>> +
>> +        kill "$pid"
>> +        while [ -e "/proc/$pid" ]; do sleep 0.1; done
> 
> This may end up looping indefinitely if for some reason "$pid" refuses
> to terminate, e.g., ignores SIGTERM.
> 
> I think we'll need a sort of "kill_insist()" or similar function that
> would force a SIGKILL to be sent if the process doesn't end in time, e.g.:
> 
> kill_insist() {
>     local pid=$1
>     local timeout_ms=${2:-30000}
> 
>     env kill -0 "$pid" 2> /dev/null && \
>         env kill --timeout "$timeout_ms" KILL "$pid"
> }
> 
> We could use this function inside cleanup() too where we're currently
> cleaning up any leftover valgrind processes.
> 
> We need "env" because the bash builtin kill doesn't support --timeout.
> 
> The rest looks good to me so, aside from the load balancer group comment
> and the check/note about valgrind not working with ubsan/asan (discussed
> in the other thread) which can be potential follow ups, I'm considering
> applying this patch to main with the following incremental change
> squashed in.  Please let me know what you think about it.

This is a good edit, I'm fine with it being squashed into my patch.
Thank you so much!

> 
> Thanks,
> Dumitru
> 
> diff --git a/tutorial/ovn-benchmark.sh b/tutorial/ovn-benchmark.sh
> index e68ef6a4e6..01c090e846 100755
> --- a/tutorial/ovn-benchmark.sh
> +++ b/tutorial/ovn-benchmark.sh
> @@ -23,29 +23,24 @@ on_interrupt() {
>  }
>  trap on_interrupt INT
> 
> +kill_insist() {
> +    local pid=$1
> +    local timeout_ms=${2:-30000}
> +
> +    env kill -0 "$pid" 2> /dev/null && \
> +        env kill --timeout "$timeout_ms" KILL "$pid"
> +}
> +
>  # In valgrind mode, the original daemons are killed and replaced with
>  # valgrind-wrapped copies.  This cleanup ensures they are restored on
>  # both normal exit and Ctrl+C so the sandbox is not left broken.
>  cleanup() {
>      # Stop any valgrind processes still running.
>      for vpid in "${VALGRIND_PIDS[@]}"; do
> -        kill "$vpid" 2>/dev/null
> +        kill_insist $vpid
>      done
> 
>      if [ -n "$BENCHMARK_TMPDIR" ]; then
> -        deadline=$(($(date +%s) + 30))
> -        for vpid in "${VALGRIND_PIDS[@]}"; do
> -            while [ -e "/proc/$vpid" ]; do
> -                if [ "$(date +%s)" -ge "$deadline" ]; then
> -                    echo "Warning: valgrind PID $vpid" \
> -                         "did not exit; killing"
> -                    kill -9 "$vpid" 2>/dev/null
> -                    break
> -                fi
> -                sleep 0.1
> -            done
> -        done
> -
>          # Restart each daemon from the cmdline/cwd saved before we
>          # killed it, but only if it is not already running.
>          for i in "${!PROCESS_NAME[@]}"; do
> @@ -278,8 +273,7 @@ if [ "$VALGRIND_MODE" = true ]; then
>              echo "Stopping $pn (PID $pid)..."
>          fi
> 
> -        kill "$pid"
> -        while [ -e "/proc/$pid" ]; do sleep 0.1; done
> +        kill_insist $pid
> 
>          if [ "$DEBUG" = true ]; then
>              echo "Starting $pn under valgrind:"

_______________________________________________
dev mailing list
[email protected]
https://mail.openvswitch.org/mailman/listinfo/ovs-dev

Reply via email to