Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/kernel-dataplane.yml
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,23 @@ jobs:
topology: tests/interop/m50-fib-ecmp-frr.clab.yml
script: tests/interop/scripts/test-m50-fib-ecmp-frr.sh

m52:
name: M52 — ADR-0066 multipath-relax (FRR 10.3.1, mixed ASes)
runs-on: [self-hosted, linux, kernel-dataplane]
needs: build-image
environment:
name: ${{ github.event_name == 'pull_request' && (github.actor != 'lance0' || github.event.pull_request.head.repo.full_name != github.repository) && 'kernel-dataplane' || 'kernel-dataplane-auto' }}
timeout-minutes: 25
steps:
- uses: actions/checkout@v6

- name: Run M52 (deploy + test + destroy with retry)
uses: ./.github/actions/run-interop-test
with:
label: M52
topology: tests/interop/m52-fib-ecmp-relax-frr.clab.yml
script: tests/interop/scripts/test-m52-fib-ecmp-relax-frr.sh

m51:
name: M51 — ADR-0067 single-hop BFD + RFC 5882 coupling (FRR 10.3.1)
runs-on: [self-hosted, linux, kernel-dataplane]
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **ADR-0066 multipath-relax.** New global `[global].multipath_relax` (default
`false`) relaxes unicast ECMP grouping from an exact `AS_PATH` match to
`AS_PATH`-*length* equality, so equal-length paths through different ASes
co-install as multipath (FRR's `bgp bestpath as-path multipath-relax`). It is
a best-path-wide knob (the FIB install-candidate query groups once at the
widest `maximum_paths`), threaded into `multipath_equal`; eBGP/iBGP class
homogeneity and all other best-path tie conditions are unchanged. Inert unless
a `[[fib_tables]]` sets `maximum_paths > 1`.

## [0.28.0] — 2026-05-24

### Added
Expand Down
48 changes: 33 additions & 15 deletions crates/rib/src/best_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,12 +259,21 @@ pub fn best_path_cmp(a: &Route, b: &Route) -> Ordering {
/// install as the single best path — so a `RouteOrigin::Local` on either side
/// disqualifies grouping (see `same_multipath_class`).
#[must_use]
pub fn multipath_equal(best: &Route, other: &Route) -> bool {
pub fn multipath_equal(best: &Route, other: &Route, relax: bool) -> bool {
// `relax` is ADR-0066 multipath-relax (FRR's `bgp bestpath as-path
// multipath-relax`): group by AS_PATH *length* rather than an exact AS_PATH
// match, so equal-length paths through different ASes co-install as ECMP.
// Off by default — the strict path compares the full AS_PATH.
let as_path_equal = if relax {
best.as_path().map_or(0, AsPath::len) == other.as_path().map_or(0, AsPath::len)
} else {
best.as_path() == other.as_path()
};
stale_rank(best) == stale_rank(other)
&& rpki_preference(best.validation_state) == rpki_preference(other.validation_state)
&& aspa_preference(best.aspa_state) == aspa_preference(other.aspa_state)
&& best.local_pref() == other.local_pref()
&& best.as_path() == other.as_path()
&& as_path_equal
&& best.origin() == other.origin()
&& best.med() == other.med()
&& same_multipath_class(best, other)
Expand Down Expand Up @@ -361,42 +370,49 @@ mod tests {
// (the tiebreakers we deliberately ignore) → co-installable.
let best = base_route(Ipv4Addr::new(1, 0, 0, 1));
let other = base_route(Ipv4Addr::new(1, 0, 0, 2));
assert!(multipath_equal(&best, &other));
assert!(multipath_equal(&best, &other, false));
}

#[test]
fn multipath_equal_false_on_local_pref() {
let best = with_local_pref(base_route(Ipv4Addr::new(1, 0, 0, 1)), 200);
let other = with_local_pref(base_route(Ipv4Addr::new(1, 0, 0, 2)), 100);
assert!(!multipath_equal(&best, &other));
assert!(!multipath_equal(&best, &other, false));
// Relax only touches AS_PATH — LOCAL_PREF still disqualifies.
assert!(!multipath_equal(&best, &other, true));
}

#[test]
fn multipath_equal_false_on_as_path_length() {
let best = with_as_path(base_route(Ipv4Addr::new(1, 0, 0, 1)), vec![65001]);
let other = with_as_path(base_route(Ipv4Addr::new(1, 0, 0, 2)), vec![65001, 65002]);
assert!(!multipath_equal(&best, &other));
assert!(!multipath_equal(&best, &other, false));
// Different *lengths* never group, even with relax.
assert!(!multipath_equal(&best, &other, true));
}

#[test]
fn multipath_equal_false_on_exact_as_path_same_length() {
// v1 requires *exact* AS_PATH: equal length but different ASNs do NOT
// group (this is the conservative default; multipath-relax is future).
fn multipath_equal_exact_as_path_strict_vs_relax() {
// Equal length, different ASNs: strict mode refuses (exact AS_PATH);
// multipath-relax groups them (ADR-0066, FRR's as-path multipath-relax).
let best = with_as_path(base_route(Ipv4Addr::new(1, 0, 0, 1)), vec![65001, 65010]);
let other = with_as_path(base_route(Ipv4Addr::new(1, 0, 0, 2)), vec![65001, 65020]);
assert!(!multipath_equal(&best, &other));
assert!(!multipath_equal(&best, &other, false));
assert!(multipath_equal(&best, &other, true));
}

#[test]
fn multipath_equal_false_on_origin_and_med() {
let best = base_route(Ipv4Addr::new(1, 0, 0, 1));
assert!(!multipath_equal(
&best,
&with_origin(base_route(Ipv4Addr::new(1, 0, 0, 2)), Origin::Egp)
&with_origin(base_route(Ipv4Addr::new(1, 0, 0, 2)), Origin::Egp),
false
));
assert!(!multipath_equal(
&best,
&with_med(base_route(Ipv4Addr::new(1, 0, 0, 2)), 50)
&with_med(base_route(Ipv4Addr::new(1, 0, 0, 2)), 50),
false
));
}

Expand All @@ -405,7 +421,9 @@ mod tests {
// Groups are homogeneous: never bundle an eBGP path with an iBGP path.
let ebgp = base_route(Ipv4Addr::new(1, 0, 0, 1));
let ibgp = with_ibgp(base_route(Ipv4Addr::new(1, 0, 0, 2)));
assert!(!multipath_equal(&ebgp, &ibgp));
assert!(!multipath_equal(&ebgp, &ibgp, false));
// Relax does not cross the eBGP/iBGP class boundary.
assert!(!multipath_equal(&ebgp, &ibgp, true));
}

#[test]
Expand All @@ -416,9 +434,9 @@ mod tests {
let ibgp = with_ibgp(base_route(Ipv4Addr::new(1, 0, 0, 1)));
let local1 = with_local(base_route(Ipv4Addr::new(1, 0, 0, 2)));
let local2 = with_local(base_route(Ipv4Addr::new(1, 0, 0, 3)));
assert!(!multipath_equal(&local1, &ibgp));
assert!(!multipath_equal(&ibgp, &local1));
assert!(!multipath_equal(&local1, &local2));
assert!(!multipath_equal(&local1, &ibgp, false));
assert!(!multipath_equal(&ibgp, &local1, false));
assert!(!multipath_equal(&local1, &local2, false));
}

// --- Decision step tests ---
Expand Down
11 changes: 8 additions & 3 deletions crates/rib/src/manager/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -506,8 +506,12 @@ impl RibManager {
self.handle_query_received_routes(peer, reply);
}
RibUpdate::QueryBestRoutes { reply } => self.handle_query_best_routes(reply),
RibUpdate::QueryFibInstallCandidates { max_paths, reply } => {
self.handle_query_fib_install_candidates(max_paths, reply);
RibUpdate::QueryFibInstallCandidates {
max_paths,
relax,
reply,
} => {
self.handle_query_fib_install_candidates(max_paths, relax, reply);
}
RibUpdate::QueryPeerGroups { reply } => self.handle_query_peer_groups(reply),
RibUpdate::QueryAdvertisedRoutes { peer, reply } => {
Expand Down Expand Up @@ -660,6 +664,7 @@ impl RibManager {
fn handle_query_fib_install_candidates(
&mut self,
max_paths: u32,
relax: bool,
reply: tokio::sync::oneshot::Sender<Vec<crate::route::FibInstallCandidate>>,
) {
use crate::best_path::multipath_equal;
Expand All @@ -685,7 +690,7 @@ impl RibManager {
.ribs
.values()
.flat_map(|rib| rib.iter_prefix(&best.prefix))
.filter(|r| multipath_equal(best, r))
.filter(|r| multipath_equal(best, r, relax))
.collect();
siblings.sort_by(|a, b| {
a.next_hop
Expand Down
65 changes: 65 additions & 0 deletions crates/rib/src/manager/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,19 @@ async fn query_best_routes(tx: &mpsc::Sender<RibUpdate>) -> Vec<Route> {
async fn query_fib_install_candidates(
tx: &mpsc::Sender<RibUpdate>,
max_paths: u32,
) -> Vec<crate::route::FibInstallCandidate> {
query_fib_install_candidates_relax(tx, max_paths, false).await
}

async fn query_fib_install_candidates_relax(
tx: &mpsc::Sender<RibUpdate>,
max_paths: u32,
relax: bool,
) -> Vec<crate::route::FibInstallCandidate> {
let (reply_tx, reply_rx) = oneshot::channel();
tx.send(RibUpdate::QueryFibInstallCandidates {
max_paths,
relax,
reply: reply_tx,
})
.await
Expand Down Expand Up @@ -10017,6 +10026,62 @@ async fn fib_install_candidates_groups_equal_cost_ecmp() {
handle.await.unwrap();
}

#[tokio::test]
async fn fib_install_candidates_multipath_relax_groups_different_as_paths() {
let (tx, rx) = mpsc::channel(64);
let manager = RibManager::new(rx, dummy_query_rx(), None, None, BgpMetrics::new());
let handle = tokio::spawn(manager.run());

let prefix = Ipv4Prefix::new(Ipv4Addr::new(10, 0, 0, 0), 24);
let peer1 = Ipv4Addr::new(1, 0, 0, 1);
let peer2 = Ipv4Addr::new(1, 0, 0, 2);
// Same AS_PATH *length* (2), different ASNs: strict refuses to group, relax
// (ADR-0066 multipath-relax) groups them as ECMP.
tx.send(RibUpdate::RoutesReceived {
peer: IpAddr::V4(peer1),
announced: vec![make_route_with_as_path(prefix, peer1, vec![65001, 65010])],
withdrawn: vec![],
flowspec_announced: vec![],
flowspec_withdrawn: vec![],
evpn_announced: vec![],
evpn_withdrawn: vec![],
})
.await
.unwrap();
tx.send(RibUpdate::RoutesReceived {
peer: IpAddr::V4(peer2),
announced: vec![make_route_with_as_path(prefix, peer2, vec![65001, 65020])],
withdrawn: vec![],
flowspec_announced: vec![],
flowspec_withdrawn: vec![],
evpn_announced: vec![],
evpn_withdrawn: vec![],
})
.await
.unwrap();

// Strict: exact AS_PATH required → only the best installs (1 next-hop).
let strict = query_fib_install_candidates_relax(&tx, 2, false).await;
assert_eq!(strict.len(), 1);
assert_eq!(
strict[0].next_hops.len(),
1,
"strict mode: different AS_PATHs do not group"
);

// Relax: equal-length AS_PATHs co-install (2 next-hops).
let relaxed = query_fib_install_candidates_relax(&tx, 2, true).await;
assert_eq!(relaxed.len(), 1);
assert_eq!(
relaxed[0].next_hops.len(),
2,
"multipath-relax: equal-length AS_PATHs group as ECMP"
);

drop(tx);
handle.await.unwrap();
}

#[tokio::test]
async fn fib_install_candidates_respects_max_paths() {
let (tx, rx) = mpsc::channel(64);
Expand Down
3 changes: 3 additions & 0 deletions crates/rib/src/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,9 @@ pub enum RibUpdate {
QueryFibInstallCandidates {
/// Max equal-cost next-hops per prefix (per-table `maximum_paths`).
max_paths: u32,
/// ADR-0066 multipath-relax: group equal-cost candidates by `AS_PATH`
/// *length* rather than an exact `AS_PATH` match (global best-path knob).
relax: bool,
/// Response channel.
reply: oneshot::Sender<Vec<FibInstallCandidate>>,
},
Expand Down
14 changes: 8 additions & 6 deletions docs/COMPARISON.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,12 +173,14 @@ for the EVPN gate ladder.

[^multipath]: Classic unicast multipath/ECMP FIB install ships (ADR-0066):
`[[fib_tables]].maximum_paths` selects N equal-cost BGP paths per prefix
(homogeneous eBGP **or** iBGP, exact-`AS_PATH` equality) and installs them
as a kernel `RTA_MULTIPATH` route — opt-in per table, default `1` (single
next-hop). Add-Path multi-path *send* (RFC 7911, route-server mode) and
EVPN aliasing ECMP (ADR-0059 FDB nexthop groups, default-on) also ship.
`AS_PATH`-length-only relaxation (`multipath-relax`) is the remaining
follow-up.
(homogeneous eBGP **or** iBGP) and installs them as a kernel `RTA_MULTIPATH`
route — opt-in per table, default `1` (single next-hop). The global
`[global].multipath_relax` knob relaxes the default exact-`AS_PATH` grouping
to `AS_PATH`-length-only (FRR's `bgp bestpath as-path multipath-relax`).
Add-Path multi-path *send* (RFC 7911, route-server mode) and EVPN aliasing
ECMP (ADR-0059 FDB nexthop groups, default-on) also ship. Per-class
`maximum_paths_ebgp`/`ibgp` and weighted/unequal-cost multipath are the
remaining follow-ups.

## Memory (200k prefixes, bgperf2)

Expand Down
1 change: 1 addition & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Required. Defines the local BGP speaker identity.
| `install_blackhole_discard` | bool | no | `false` | Install kernel blackhole routes for accepted RFC 7999 host routes — see below |
| `allow_blackhole_broad_prefixes` | bool | no | `false` | Permit non-host BLACKHOLE discard installs when the FIB slice is enabled |
| `apply_bum_enforcement` | bool | no | `true` (since v0.23.0) | Apply Gate 8b BUM-suppression filters to the kernel per-port `IFLA_BRPORT_*_FLOOD` triplet. Restart-required. Default flipped to `true` after the Gate 8b 24 h MAC-churn soak (2026-05-16) and the M37 local-origination 24 h MAC-churn soak (2026-05-19) both passed. Operators who need the prior observe-only posture must set `apply_bum_enforcement = false` explicitly |
| `multipath_relax` | bool | no | `false` | ADR-0066 multipath-relax: group unicast ECMP candidates by `AS_PATH` *length* instead of an exact `AS_PATH` match (FRR's `bgp bestpath as-path multipath-relax`). Best-path-wide; inert unless a `[[fib_tables]]` sets `maximum_paths > 1` |

```toml
[global]
Expand Down
Loading
Loading