Add NB and SB scope tables and validate gateway placement and physical
L2 membership before activating a scope. Store SB policy in options,
allocate stable scope keys, and generate scoped neighbor lookup flows.
Negotiate controller support and age shared bindings with scope policy.

Keep shared bindings indexed by scope and IP. A scope-owned binding set
would make every learned neighbor mutate the parent scope and remove the
schema uniqueness constraint on (scope, ip). Northd instead removes the
bindings and scope together when withdrawing a scope.

Add schema documentation, northd tests, and shared lookups in ovn-trace.

Assisted-by: OpenAI Codex
Signed-off-by: Tim Rozet <[email protected]>
---
 Documentation/ref/ovn-logical-flows.7.rst |  17 +-
 NEWS                                      |   3 +
 include/ovn/features.h                    |   1 +
 northd/aging.c                            |  61 ++++
 northd/aging.h                            |   1 +
 northd/automake.mk                        |   2 +
 northd/en-global-config.c                 |  21 +-
 northd/en-global-config.h                 |   1 +
 northd/en-mac-binding-scope.c             | 406 ++++++++++++++++++++++
 northd/en-mac-binding-scope.h             |  26 ++
 northd/inc-proc-northd.c                  |  32 +-
 northd/northd.c                           | 202 +++++++++--
 northd/ovn-northd.c                       |  13 +
 ovn-nb.ovsschema                          |  26 +-
 ovn-nb.xml                                |  75 ++++
 ovn-sb.ovsschema                          |  36 +-
 ovn-sb.xml                                |  82 +++++
 tests/ovn-inc-proc-graph-dump.at          |  15 +
 tests/ovn-northd.at                       | 195 ++++++++++-
 tests/ovn.at                              |   2 +-
 utilities/ovn-trace.c                     |  90 ++++-
 21 files changed, 1246 insertions(+), 61 deletions(-)
 create mode 100644 northd/en-mac-binding-scope.c
 create mode 100644 northd/en-mac-binding-scope.h

diff --git a/Documentation/ref/ovn-logical-flows.7.rst 
b/Documentation/ref/ovn-logical-flows.7.rst
index 913ebbc83..5529da206 100644
--- a/Documentation/ref/ovn-logical-flows.7.rst
+++ b/Documentation/ref/ovn-logical-flows.7.rst
@@ -3510,11 +3510,11 @@ contains the final destination.)  This table resolves 
the IP address in ``reg0``
   and the destination IP of the packet is still a router owned IP, the packets
   can be safely dropped.
 
-  A priority-2 logical flow with match ``ip4.dst = {..}`` matches on traffic
+  A priority-3 logical flow with match ``ip4.dst = {..}`` matches on traffic
   destined to router owned IPv4 addresses which are also SNAT IPs. This flow 
has
   action ``drop;``.
 
-  A priority-2 logical flow with match ``ip6.dst = {..}`` matches on traffic
+  A priority-3 logical flow with match ``ip6.dst = {..}`` matches on traffic
   destined to router owned IPv6 addresses which are also SNAT IPs. This flow 
has
   action ``drop;``.
 
@@ -3526,11 +3526,16 @@ contains the final destination.)  This table resolves 
the IP address in ``reg0``
   :ref:`ARP Request <lr-in-27>` will issue an ARP or neighbor solicitation
   request for cases where the binding is not yet known.)
 
-  A priority-0 logical flow with match ``ip4`` has actions ``get_arp(outport,
-  reg0); next;``.
+  A priority-1 logical flow with match ``reg9[9] == 1`` has actions
+  ``get_arp(outport, reg0); next;``.
 
-  A priority-0 logical flow with match ``ip6`` has actions ``get_nd(outport,
-  xxreg0); next;``.
+  A priority-1 logical flow with match ``reg9[9] == 0`` has actions
+  ``get_nd(outport, xxreg0); next;``.
+
+  For a port in an active MAC binding scope, priority-2 flows additionally
+  match ``outport`` and pass the scope's binding key as the final argument
+  to ``get_arp`` or ``get_nd``.  Router-owned destination drops take
+  precedence over these scoped lookups.
 
 - For a distributed gateway LRP with ``redirect-type`` set to ``bridged``, a
   priority-50 flow will match ``outport == "ROUTER_PORT" and
diff --git a/NEWS b/NEWS
index 4176f8d7f..0f829955b 100644
--- a/NEWS
+++ b/NEWS
@@ -9,6 +9,9 @@ Post v26.09.0
      port_security and "unknown" addresses configured.
    - Added a new "options:ttl" key on the NB DNS table to make the TTL of
      DNS replies from OVN's native DNS resolver configurable per row.
+   - Logical router ports can share dynamically learned IP-to-MAC bindings
+     through MAC_Binding_Scope, reducing SB rows and controller flows when
+     co-located routers use the same physical L2 domain.
    - Removed implementations of the commit_ecmp_nh, chk_ecmp_nh, and
      chk_ecmp_nh_mac actions from the code.
    - Mark tunnel ports as transient (other_config:transient=true) when the
diff --git a/include/ovn/features.h b/include/ovn/features.h
index 7207da67f..adfd7e42c 100644
--- a/include/ovn/features.h
+++ b/include/ovn/features.h
@@ -27,6 +27,7 @@
 #define OVN_FEATURE_CT_NEXT_ZONE "ct-next-zone"
 #define OVN_FEATURE_CT_LABEL_FLUSH "ct-label-flush"
 #define OVN_FEATURE_CT_STATE_SAVE "ct-state-save"
+#define OVN_FEATURE_SHARED_MAC_BINDING "shared-mac-binding"
 
 /* DEPRECATED: The following features can be removed
  * after the next LTS version release. */
diff --git a/northd/aging.c b/northd/aging.c
index 3fe58ed14..a49047e33 100644
--- a/northd/aging.c
+++ b/northd/aging.c
@@ -217,6 +217,18 @@ exit:
     return result;
 }
 
+bool
+mac_binding_age_threshold_is_valid(const char *opt)
+{
+    struct threshold_config config;
+    if (!parse_aging_threshold(opt, &config)) {
+        return false;
+    }
+
+    threshold_config_destroy(&config);
+    return true;
+}
+
 static unsigned int
 find_threshold_for_ip(const char *ip_str,
                       const struct threshold_config *config)
@@ -386,6 +398,29 @@ mac_binding_aging_run_for_datapath(const struct 
sbrec_datapath_binding *dp,
     sbrec_mac_binding_index_destroy_row(mb_index_row);
 }
 
+static void
+mac_binding_aging_run_for_scope(
+    const struct sbrec_mac_binding_scope *scope,
+    struct ovsdb_idl_index *shared_mb_by_scope,
+    struct aging_context *ctx)
+{
+    struct sbrec_shared_mac_binding *target =
+        sbrec_shared_mac_binding_index_init_row(shared_mb_by_scope);
+    sbrec_shared_mac_binding_index_set_scope(target, scope);
+
+    const struct sbrec_shared_mac_binding *mb;
+    SBREC_SHARED_MAC_BINDING_FOR_EACH_EQUAL (
+        mb, target, shared_mb_by_scope) {
+        if (aging_context_handle_timestamp(ctx, mb->timestamp, mb->ip)) {
+            sbrec_shared_mac_binding_delete(mb);
+            if (aging_context_is_at_limit(ctx)) {
+                break;
+            }
+        }
+    }
+    sbrec_shared_mac_binding_index_destroy_row(target);
+}
+
 enum engine_node_state
 en_mac_binding_aging_run(struct engine_node *node, void *data OVS_UNUSED)
 {
@@ -404,6 +439,10 @@ en_mac_binding_aging_run(struct engine_node *node, void 
*data OVS_UNUSED)
     struct ovsdb_idl_index *sbrec_mac_binding_by_datapath =
         engine_ovsdb_node_get_index(engine_get_input("SB_mac_binding", node),
                                     "sbrec_mac_binding_by_datapath");
+    struct ovsdb_idl_index *shared_mb_by_scope =
+        engine_ovsdb_node_get_index(
+            engine_get_input("SB_shared_mac_binding", node),
+            "sbrec_shared_mac_binding_by_scope");
 
     struct ovn_datapath *od;
     HMAP_FOR_EACH (od, key_node, &northd_data->lr_datapaths.datapaths) {
@@ -434,6 +473,28 @@ en_mac_binding_aging_run(struct engine_node *node, void 
*data OVS_UNUSED)
         }
     }
 
+    if (!aging_context_is_at_limit(&ctx)) {
+        const struct sbrec_mac_binding_scope_table *scope_table =
+            EN_OVSDB_GET(engine_get_input("SB_mac_binding_scope", node));
+        const struct sbrec_mac_binding_scope *scope;
+        SBREC_MAC_BINDING_SCOPE_TABLE_FOR_EACH (scope, scope_table) {
+            struct threshold_config threshold_config;
+            if (!parse_aging_threshold(smap_get(&scope->options,
+                                               "mac_binding_age_threshold"),
+                                       &threshold_config)) {
+                continue;
+            }
+
+            aging_context_set_threshold(&ctx, &threshold_config);
+            mac_binding_aging_run_for_scope(scope, shared_mb_by_scope, &ctx);
+            threshold_config_destroy(&threshold_config);
+            if (aging_context_is_at_limit(&ctx)) {
+                ctx.next_wake_ms = AGING_BULK_REMOVAL_DELAY_MSEC;
+                break;
+            }
+        }
+    }
+
     aging_waker_schedule_next_wake(waker, ctx.next_wake_ms);
 
     return EN_UPDATED;
diff --git a/northd/aging.h b/northd/aging.h
index 76dadb951..9d55f9b00 100644
--- a/northd/aging.h
+++ b/northd/aging.h
@@ -34,6 +34,7 @@ void en_mac_binding_aging_waker_cleanup(void *data);
 
 /* The MAC binding aging helper function. */
 unsigned int min_mac_binding_age_threshold(const char *opt);
+bool mac_binding_age_threshold_is_valid(const char *opt);
 
 /* The FDB aging node functions. */
 enum engine_node_state en_fdb_aging_run(struct engine_node *node, void *data);
diff --git a/northd/automake.mk b/northd/automake.mk
index 1d3044930..5ae59b68f 100644
--- a/northd/automake.mk
+++ b/northd/automake.mk
@@ -22,6 +22,8 @@ northd_ovn_northd_SOURCES = \
        northd/en-ecmp-nexthop.h \
        northd/en-global-config.c \
        northd/en-global-config.h \
+       northd/en-mac-binding-scope.c \
+       northd/en-mac-binding-scope.h \
        northd/en-northd.c \
        northd/en-northd.h \
        northd/en-lflow.c \
diff --git a/northd/en-global-config.c b/northd/en-global-config.c
index 2558f5955..7528fa96a 100644
--- a/northd/en-global-config.c
+++ b/northd/en-global-config.c
@@ -217,9 +217,10 @@ en_global_config_run(struct engine_node *node , void *data)
         nbrec_nb_global_set_options(nb, options);
     }
 
-    if (smap_get_bool(&nb->options, "ignore_chassis_features", false)) {
-        northd_enable_all_features(config_data);
-    } else {
+    /* A full recompute must also be able to re-enable features.  The
+     * capability scan only clears flags for unsupported features. */
+    northd_enable_all_features(config_data);
+    if (!smap_get_bool(&nb->options, "ignore_chassis_features", false)) {
         build_chassis_features(sbrec_chassis_table, &config_data->features);
     }
 
@@ -520,6 +521,7 @@ northd_enable_all_features(struct ed_type_global_config 
*data)
         .ct_next_zone = true,
         .ct_label_flush = true,
         .ct_state_save = true,
+        .shared_mac_binding = true,
     };
 }
 
@@ -596,6 +598,15 @@ build_chassis_features(const struct sbrec_chassis_table 
*sbrec_chassis_table,
             chassis_features->ct_state_save) {
             chassis_features->ct_state_save = false;
         }
+
+        bool shared_mac_binding =
+                smap_get_bool(&chassis->other_config,
+                              OVN_FEATURE_SHARED_MAC_BINDING,
+                              false);
+        if (!shared_mac_binding &&
+            chassis_features->shared_mac_binding) {
+            chassis_features->shared_mac_binding = false;
+        }
     }
 }
 
@@ -753,6 +764,10 @@ chassis_features_changed(const struct chassis_features 
*present,
         return true;
     }
 
+    if (present->shared_mac_binding != updated->shared_mac_binding) {
+        return true;
+    }
+
     return false;
 }
 
diff --git a/northd/en-global-config.h b/northd/en-global-config.h
index 5a7b7ecda..adf2b6638 100644
--- a/northd/en-global-config.h
+++ b/northd/en-global-config.h
@@ -20,6 +20,7 @@ struct chassis_features {
     bool ct_next_zone;
     bool ct_label_flush;
     bool ct_state_save;
+    bool shared_mac_binding;
 };
 
 struct global_config_tracked_data {
diff --git a/northd/en-mac-binding-scope.c b/northd/en-mac-binding-scope.c
new file mode 100644
index 000000000..5308a9170
--- /dev/null
+++ b/northd/en-mac-binding-scope.c
@@ -0,0 +1,406 @@
+/*
+ * Copyright (c) 2026, NVIDIA CORPORATION.  All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at:
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include <config.h>
+
+#include "en-global-config.h"
+#include "en-mac-binding-scope.h"
+#include "lib/ovn-nb-idl.h"
+#include "lib/ovn-sb-idl.h"
+#include "northd/aging.h"
+#include "northd.h"
+#include "openvswitch/hmap.h"
+#include "openvswitch/shash.h"
+#include "openvswitch/vlog.h"
+
+VLOG_DEFINE_THIS_MODULE(mac_binding_scope);
+
+struct scope_constraints {
+    const char *chassis;
+    const struct sbrec_ha_chassis_group *ha_group;
+    const char *network_name;
+    const char *ethertype;
+    int64_t vlan;
+    bool initialized;
+};
+
+static const char *
+ha_chassis_name(const struct sbrec_ha_chassis *ha_chassis)
+{
+    return ha_chassis->chassis
+           ? ha_chassis->chassis->name
+           : smap_get(&ha_chassis->external_ids, "chassis-name");
+}
+
+static bool
+ha_chassis_group_is_single_chassis(
+    const struct sbrec_ha_chassis_group *group, const char *chassis)
+{
+    const char *group_chassis = group->n_ha_chassis == 1
+        ? ha_chassis_name(group->ha_chassis[0]) : NULL;
+    return group_chassis && !strcmp(group_chassis, chassis);
+}
+
+static bool
+ha_chassis_groups_have_same_placement(
+    const struct sbrec_ha_chassis_group *a,
+    const struct sbrec_ha_chassis_group *b)
+{
+    if (a == b) {
+        return true;
+    }
+    if (a->n_ha_chassis != b->n_ha_chassis) {
+        return false;
+    }
+
+    for (size_t i = 0; i < a->n_ha_chassis; i++) {
+        const struct sbrec_ha_chassis *a_chassis = a->ha_chassis[i];
+        const char *a_name = ha_chassis_name(a_chassis);
+        bool found = false;
+
+        for (size_t j = 0; j < b->n_ha_chassis; j++) {
+            const struct sbrec_ha_chassis *b_chassis = b->ha_chassis[j];
+            const char *b_name = ha_chassis_name(b_chassis);
+            if (a_name && b_name && !strcmp(a_name, b_name) &&
+                a_chassis->priority == b_chassis->priority) {
+                found = true;
+                break;
+            }
+        }
+        if (!found) {
+            return false;
+        }
+    }
+    return true;
+}
+
+static bool
+scope_placements_match(const struct scope_constraints *constraints,
+                       const char *chassis,
+                       const struct sbrec_ha_chassis_group *ha_group)
+{
+    if (constraints->chassis && chassis) {
+        return !strcmp(constraints->chassis, chassis);
+    }
+    if (constraints->ha_group && ha_group) {
+        return ha_chassis_groups_have_same_placement(
+            constraints->ha_group, ha_group);
+    }
+    if (constraints->chassis && ha_group) {
+        return ha_chassis_group_is_single_chassis(
+            ha_group, constraints->chassis);
+    }
+    if (constraints->ha_group && chassis) {
+        return ha_chassis_group_is_single_chassis(
+            constraints->ha_group, chassis);
+    }
+    return false;
+}
+
+static bool
+scope_member_validate(const struct ovn_port *op,
+                      struct scope_constraints *constraints,
+                      const char **reason)
+{
+    if (!op->sb || !op->peer || !op->peer->od ||
+        vector_len(&op->peer->od->localnet_ports) != 1) {
+        *reason = "each member must connect to one localnet port";
+        return false;
+    }
+
+    const struct ovn_port *localnet = vector_get(
+        &op->peer->od->localnet_ports, 0, const struct ovn_port *);
+    const char *network_name = smap_get(&localnet->nbsp->options,
+                                        "network_name");
+    if (!network_name || !network_name[0]) {
+        *reason = "the localnet port must have a network_name";
+        return false;
+    }
+
+    const char *chassis = smap_get(&op->od->nbr->options, "chassis");
+    const struct sbrec_ha_chassis_group *ha_group = NULL;
+    if (!chassis && op->cr_port && op->cr_port->sb) {
+        ha_group = op->cr_port->sb->ha_chassis_group;
+    }
+    if (!chassis && !ha_group) {
+        *reason = "each member must have a gateway chassis placement";
+        return false;
+    }
+
+    int64_t vlan = localnet->nbsp->n_tag ? localnet->nbsp->tag[0] : 0;
+    const char *ethertype = smap_get_def(&localnet->nbsp->options,
+                                         "ethtype", "802.1q");
+    if (!constraints->initialized) {
+        *constraints = (struct scope_constraints) {
+            .chassis = chassis,
+            .ha_group = ha_group,
+            .network_name = network_name,
+            .ethertype = ethertype,
+            .vlan = vlan,
+            .initialized = true,
+        };
+        return true;
+    }
+
+    if (!scope_placements_match(constraints, chassis, ha_group)) {
+        *reason = "all members must share one gateway chassis placement";
+        return false;
+    }
+    if (strcmp(constraints->network_name, network_name) ||
+        constraints->vlan != vlan ||
+        strcmp(constraints->ethertype, ethertype)) {
+        *reason = "all members must share one physical L2 domain";
+        return false;
+    }
+    return true;
+}
+
+static bool
+scope_validate(const struct nbrec_mac_binding_scope *scope,
+               const struct northd_data *northd_data,
+               struct vector *members, const char **reason)
+{
+    if (scope->mac_binding_age_threshold &&
+        !mac_binding_age_threshold_is_valid(
+            scope->mac_binding_age_threshold)) {
+        *reason = "the MAC binding aging threshold is invalid";
+        return false;
+    }
+
+    const struct ovn_port *op;
+    struct scope_constraints constraints = {0};
+
+    HMAP_FOR_EACH (op, key_node, &northd_data->lr_ports) {
+        if (!op->nbrp || op->primary_port ||
+            op->nbrp->mac_binding_scope != scope) {
+            continue;
+        }
+        vector_push(members, &op);
+        if (!scope_member_validate(op, &constraints, reason)) {
+            return false;
+        }
+    }
+
+    if (vector_is_empty(members)) {
+        *reason = "the scope has no router port members";
+        return false;
+    }
+    return true;
+}
+
+static bool
+scope_copy_to_sb(const struct nbrec_mac_binding_scope *nb_scope,
+                 const struct sbrec_mac_binding_scope *sb_scope,
+                 uint32_t binding_key)
+{
+    struct smap options = SMAP_INITIALIZER(&options);
+    if (nb_scope->n_always_learn_from_arp_request) {
+        smap_add(&options, "always_learn_from_arp_request",
+                 nb_scope->always_learn_from_arp_request[0]
+                 ? "true" : "false");
+    }
+    if (nb_scope->n_disable_garp_rarp) {
+        smap_add(&options, "disable_garp_rarp",
+                 nb_scope->disable_garp_rarp[0] ? "true" : "false");
+    }
+    if (nb_scope->mac_binding_age_threshold) {
+        smap_add(&options, "mac_binding_age_threshold",
+                 nb_scope->mac_binding_age_threshold);
+    }
+    bool changed = strcmp(sb_scope->name, nb_scope->name) ||
+                   sb_scope->binding_key != binding_key ||
+                   !smap_equal(&sb_scope->options, &options);
+
+    sbrec_mac_binding_scope_set_name(sb_scope, nb_scope->name);
+    sbrec_mac_binding_scope_set_binding_key(sb_scope, binding_key);
+    sbrec_mac_binding_scope_set_options(sb_scope, &options);
+    smap_destroy(&options);
+
+    struct smap external_ids;
+    smap_clone(&external_ids, &nb_scope->external_ids);
+    smap_replace_nocopy(&external_ids, "ovn-nb-id",
+                        uuid_to_string(&nb_scope->header_.uuid));
+    changed |= !smap_equal(&sb_scope->external_ids, &external_ids);
+    sbrec_mac_binding_scope_set_external_ids(sb_scope, &external_ids);
+    smap_destroy(&external_ids);
+    return changed;
+}
+
+void *
+en_mac_binding_scope_init(struct engine_node *node OVS_UNUSED,
+                          struct engine_arg *arg OVS_UNUSED)
+{
+    return NULL;
+}
+
+enum engine_node_state
+en_mac_binding_scope_run(struct engine_node *node, void *data OVS_UNUSED)
+{
+    const struct engine_context *eng_ctx = engine_get_context();
+    const struct northd_data *northd_data =
+        engine_get_input_data("northd", node);
+    const struct ed_type_global_config *global_config =
+        engine_get_input_data("global_config", node);
+    const struct nbrec_mac_binding_scope_table *nb_scope_table =
+        EN_OVSDB_GET(engine_get_input("NB_mac_binding_scope", node));
+    const struct sbrec_mac_binding_scope_table *sb_scope_table =
+        EN_OVSDB_GET(engine_get_input("SB_mac_binding_scope", node));
+    if (!nbrec_mac_binding_scope_table_first(nb_scope_table) &&
+        !sbrec_mac_binding_scope_table_first(sb_scope_table)) {
+        return EN_UNCHANGED;
+    }
+
+    const struct sbrec_shared_mac_binding_table *shared_mb_table =
+        EN_OVSDB_GET(engine_get_input("SB_shared_mac_binding", node));
+    const struct sbrec_mac_binding_table *mac_binding_table =
+        EN_OVSDB_GET(engine_get_input("SB_mac_binding", node));
+    const struct sbrec_port_binding_table *pb_table =
+        EN_OVSDB_GET(engine_get_input("SB_port_binding", node));
+    bool changed = false;
+
+    struct shash sb_scopes = SHASH_INITIALIZER(&sb_scopes);
+    struct hmap binding_keys = HMAP_INITIALIZER(&binding_keys);
+    struct hmapx keys_preserved = HMAPX_INITIALIZER(&keys_preserved);
+    const struct sbrec_mac_binding_scope *sb_scope;
+    SBREC_MAC_BINDING_SCOPE_TABLE_FOR_EACH (sb_scope, sb_scope_table) {
+        const char *nb_id = smap_get(&sb_scope->external_ids, "ovn-nb-id");
+        if (nb_id && !shash_find(&sb_scopes, nb_id)) {
+            shash_add(&sb_scopes, nb_id, CONST_CAST(
+                struct sbrec_mac_binding_scope *, sb_scope));
+        } else if (nb_id) {
+            static struct vlog_rate_limit rl = VLOG_RATE_LIMIT_INIT(5, 1);
+            VLOG_WARN_RL(&rl, "Duplicate MAC binding scope for NB UUID %s",
+                         nb_id);
+        }
+        if (sb_scope->binding_key > 0 &&
+            sb_scope->binding_key <= OVN_MAX_DP_KEY &&
+            ovn_add_tnlid(&binding_keys, sb_scope->binding_key)) {
+            hmapx_add(&keys_preserved, CONST_CAST(
+                struct sbrec_mac_binding_scope *, sb_scope));
+        }
+    }
+
+    struct hmapx active_scopes = HMAPX_INITIALIZER(&active_scopes);
+    struct hmapx active_pbs = HMAPX_INITIALIZER(&active_pbs);
+    struct sset active_lports = SSET_INITIALIZER(&active_lports);
+    uint32_t key_hint = 0;
+    const struct nbrec_mac_binding_scope *nb_scope;
+    NBREC_MAC_BINDING_SCOPE_TABLE_FOR_EACH (nb_scope, nb_scope_table) {
+        struct vector members = VECTOR_EMPTY_INITIALIZER(
+            const struct ovn_port *);
+        const char *reason = NULL;
+        bool valid = global_config->features.shared_mac_binding &&
+                     scope_validate(nb_scope, northd_data, &members, &reason);
+        if (!valid) {
+            if (reason) {
+                static struct vlog_rate_limit rl =
+                    VLOG_RATE_LIMIT_INIT(5, 1);
+                VLOG_WARN_RL(&rl, "Ignoring MAC binding scope '%s': %s",
+                             nb_scope->name, reason);
+            }
+            vector_destroy(&members);
+            continue;
+        }
+
+        char nb_id[UUID_LEN + 1];
+        snprintf(nb_id, sizeof nb_id, UUID_FMT,
+                 UUID_ARGS(&nb_scope->header_.uuid));
+        sb_scope = shash_find_and_delete(&sb_scopes, nb_id);
+        if (!sb_scope) {
+            sb_scope = sbrec_mac_binding_scope_insert(
+                eng_ctx->ovnsb_idl_txn);
+            changed = true;
+        }
+
+        uint32_t key = hmapx_contains(&keys_preserved, sb_scope)
+                       ? sb_scope->binding_key
+                       : ovn_allocate_tnlid(&binding_keys,
+                                            "MAC binding scope", 1,
+                                            OVN_MAX_DP_KEY, &key_hint);
+        if (!key) {
+            vector_destroy(&members);
+            continue;
+        }
+
+        changed |= scope_copy_to_sb(nb_scope, sb_scope, key);
+        hmapx_add(&active_scopes, CONST_CAST(
+            struct sbrec_mac_binding_scope *, sb_scope));
+
+        const struct ovn_port *member;
+        VECTOR_FOR_EACH (&members, member) {
+            hmapx_add(&active_pbs, CONST_CAST(
+                struct sbrec_port_binding *, member->sb));
+            if (member->sb->mac_binding_scope != sb_scope) {
+                sbrec_port_binding_set_mac_binding_scope(member->sb,
+                                                         sb_scope);
+                changed = true;
+            }
+            sset_add(&active_lports, member->key);
+            if (member->cr_port && member->cr_port->sb) {
+                const struct sbrec_port_binding *cr_pb = member->cr_port->sb;
+                hmapx_add(&active_pbs, CONST_CAST(
+                    struct sbrec_port_binding *, cr_pb));
+                if (cr_pb->mac_binding_scope != sb_scope) {
+                    sbrec_port_binding_set_mac_binding_scope(cr_pb,
+                                                             sb_scope);
+                    changed = true;
+                }
+            }
+        }
+        vector_destroy(&members);
+    }
+
+    const struct sbrec_port_binding *pb;
+    SBREC_PORT_BINDING_TABLE_FOR_EACH (pb, pb_table) {
+        if (pb->mac_binding_scope && !hmapx_contains(&active_pbs, pb)) {
+            sbrec_port_binding_set_mac_binding_scope(pb, NULL);
+            changed = true;
+        }
+    }
+
+    const struct sbrec_mac_binding *mb;
+    SBREC_MAC_BINDING_TABLE_FOR_EACH (mb, mac_binding_table) {
+        if (sset_contains(&active_lports, mb->logical_port)) {
+            sbrec_mac_binding_delete(mb);
+        }
+    }
+
+    const struct sbrec_shared_mac_binding *shared_mb;
+    SBREC_SHARED_MAC_BINDING_TABLE_FOR_EACH (shared_mb, shared_mb_table) {
+        if (!hmapx_contains(&active_scopes, shared_mb->scope)) {
+            sbrec_shared_mac_binding_delete(shared_mb);
+        }
+    }
+    SBREC_MAC_BINDING_SCOPE_TABLE_FOR_EACH (sb_scope, sb_scope_table) {
+        if (!hmapx_contains(&active_scopes, sb_scope)) {
+            sbrec_mac_binding_scope_delete(sb_scope);
+            changed = true;
+        }
+    }
+
+    shash_destroy(&sb_scopes);
+    ovn_destroy_tnlids(&binding_keys);
+    hmapx_destroy(&keys_preserved);
+    hmapx_destroy(&active_scopes);
+    hmapx_destroy(&active_pbs);
+    sset_destroy(&active_lports);
+    return changed ? EN_UPDATED : EN_UNCHANGED;
+}
+
+void
+en_mac_binding_scope_cleanup(void *data OVS_UNUSED)
+{
+}
diff --git a/northd/en-mac-binding-scope.h b/northd/en-mac-binding-scope.h
new file mode 100644
index 000000000..d3af93e33
--- /dev/null
+++ b/northd/en-mac-binding-scope.h
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2026, NVIDIA CORPORATION.  All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at:
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef EN_MAC_BINDING_SCOPE_H
+#define EN_MAC_BINDING_SCOPE_H 1
+
+#include "lib/inc-proc-eng.h"
+
+void *en_mac_binding_scope_init(struct engine_node *, struct engine_arg *);
+enum engine_node_state en_mac_binding_scope_run(struct engine_node *, void *);
+void en_mac_binding_scope_cleanup(void *);
+
+#endif /* EN_MAC_BINDING_SCOPE_H */
diff --git a/northd/inc-proc-northd.c b/northd/inc-proc-northd.c
index 53604a20a..f53e02905 100644
--- a/northd/inc-proc-northd.c
+++ b/northd/inc-proc-northd.c
@@ -30,6 +30,7 @@
 #include "openvswitch/vlog.h"
 #include "inc-proc-northd.h"
 #include "en-global-config.h"
+#include "en-mac-binding-scope.h"
 #include "en-lb-data.h"
 #include "en-lr-stateful.h"
 #include "en-lr-nat.h"
@@ -77,7 +78,8 @@ static unixctl_cb_func chassis_features_list;
     NB_NODE(network_function) \
     NB_NODE(network_function_group) \
     NB_NODE(logical_switch_port_health_check) \
-    NB_NODE(logical_router_static_route)
+    NB_NODE(logical_router_static_route) \
+    NB_NODE(mac_binding_scope)
 
     enum nb_engine_node {
 #define NB_NODE(NAME) NB_##NAME,
@@ -123,7 +125,9 @@ static unixctl_cb_func chassis_features_list;
     SB_NODE(acl_id) \
     SB_NODE(advertised_route) \
     SB_NODE(learned_route) \
-    SB_NODE(advertised_mac_binding)
+    SB_NODE(advertised_mac_binding) \
+    SB_NODE(mac_binding_scope) \
+    SB_NODE(shared_mac_binding)
 
 enum sb_engine_node {
 #define SB_NODE(NAME) SB_##NAME,
@@ -197,6 +201,7 @@ static ENGINE_NODE(datapath_sync, CLEAR_TRACKED_DATA, 
SB_WRITE);
 static ENGINE_NODE(datapath_synced_logical_router, CLEAR_TRACKED_DATA);
 static ENGINE_NODE(datapath_synced_logical_switch, CLEAR_TRACKED_DATA);
 static ENGINE_NODE(ic_learned_svc_monitors, SB_WRITE);
+static ENGINE_NODE(mac_binding_scope, SB_WRITE);
 
 void inc_proc_northd_init(struct ovsdb_idl_loop *nb,
                           struct ovsdb_idl_loop *sb)
@@ -301,6 +306,17 @@ void inc_proc_northd_init(struct ovsdb_idl_loop *nb,
     engine_add_input(&en_northd, &en_nb_port_group,
                      northd_nb_port_group_handler);
 
+    engine_add_input(&en_mac_binding_scope, &en_northd, NULL);
+    engine_add_input(&en_mac_binding_scope, &en_global_config, NULL);
+    engine_add_input(&en_mac_binding_scope, &en_nb_mac_binding_scope, NULL);
+    engine_add_input(&en_mac_binding_scope, &en_sb_mac_binding_scope, NULL);
+    engine_add_input(&en_mac_binding_scope, &en_sb_shared_mac_binding,
+                     engine_noop_handler);
+    engine_add_input(&en_mac_binding_scope, &en_sb_mac_binding,
+                     engine_noop_handler);
+    engine_add_input(&en_mac_binding_scope, &en_sb_port_binding,
+                     engine_noop_handler);
+
     /* No need for an explicit handler for the SB datapath and
      * SB IP Multicast changes.*/
     engine_add_input(&en_northd, &en_sb_ip_multicast, engine_noop_handler);
@@ -318,6 +334,9 @@ void inc_proc_northd_init(struct ovsdb_idl_loop *nb,
     engine_add_input(&en_ls_stateful, &en_nb_acl, ls_stateful_acl_handler);
 
     engine_add_input(&en_mac_binding_aging, &en_sb_mac_binding, NULL);
+    engine_add_input(&en_mac_binding_aging, &en_sb_shared_mac_binding, NULL);
+    engine_add_input(&en_mac_binding_aging, &en_sb_mac_binding_scope, NULL);
+    engine_add_input(&en_mac_binding_aging, &en_mac_binding_scope, NULL);
     engine_add_input(&en_mac_binding_aging, &en_northd, NULL);
     engine_add_input(&en_mac_binding_aging, &en_mac_binding_aging_waker, NULL);
     engine_add_input(&en_mac_binding_aging, &en_global_config,
@@ -423,6 +442,7 @@ void inc_proc_northd_init(struct ovsdb_idl_loop *nb,
      */
     engine_add_input(&en_lflow, &en_datapath_sync, engine_noop_handler);
     engine_add_input(&en_lflow, &en_northd, lflow_northd_handler);
+    engine_add_input(&en_lflow, &en_mac_binding_scope, NULL);
     /* No need for an explicit handler for port_groups in the en_lflow node.
      * Stateful configuration changes are passed through the en_ls_stateful
      * input dependancy. Still needs access to en_port_port_group (input)
@@ -543,6 +563,9 @@ void inc_proc_northd_init(struct ovsdb_idl_loop *nb,
         = mac_binding_by_datapath_index_create(sb->idl);
     struct ovsdb_idl_index *sbrec_mac_binding_by_lport_ip
         = mac_binding_by_lport_ip_index_create(sb->idl);
+    struct ovsdb_idl_index *shared_mac_binding_by_scope =
+        ovsdb_idl_index_create1(sb->idl,
+                                &sbrec_shared_mac_binding_col_scope);
     struct ovsdb_idl_index *fdb_by_dp_key =
         ovsdb_idl_index_create1(sb->idl, &sbrec_fdb_col_dp_key);
 
@@ -572,6 +595,9 @@ void inc_proc_northd_init(struct ovsdb_idl_loop *nb,
     engine_ovsdb_node_add_index(&en_sb_mac_binding,
                                 "sbrec_mac_binding_by_lport_ip",
                                 sbrec_mac_binding_by_lport_ip);
+    engine_ovsdb_node_add_index(&en_sb_shared_mac_binding,
+                                "sbrec_shared_mac_binding_by_scope",
+                                shared_mac_binding_by_scope);
     engine_ovsdb_node_add_index(&en_sb_fdb,
                                 "fdb_by_dp_key",
                                 fdb_by_dp_key);
@@ -713,6 +739,8 @@ chassis_features_list(struct unixctl_conn *conn, int argc 
OVS_UNUSED,
                   features->ct_label_flush ? "true" : "false");
     ds_put_format(&ds, "ct_state_save: %s\n",
                   features->ct_state_save ? "true" : "false");
+    ds_put_format(&ds, "shared_mac_binding: %s\n",
+                  features->shared_mac_binding ? "true" : "false");
 
     unixctl_command_reply(conn, ds_cstr(&ds));
     ds_destroy(&ds);
diff --git a/northd/northd.c b/northd/northd.c
index 3f59894bd..3217cf443 100644
--- a/northd/northd.c
+++ b/northd/northd.c
@@ -15517,8 +15517,90 @@ build_neigh_learning_flows_for_lrouter_port(
 {
     ovs_assert(op->nbrp);
 
-    bool learn_from_arp_request = smap_get_bool(&op->od->nbr->options,
-        "always_learn_from_arp_request", true);
+    const struct sbrec_mac_binding_scope *scope =
+        op->sb ? op->sb->mac_binding_scope : NULL;
+    bool learn_from_arp_request = scope
+        ? smap_get_bool(&scope->options, "always_learn_from_arp_request", true)
+        : smap_get_bool(&op->od->nbr->options,
+                        "always_learn_from_arp_request", true);
+    char scope_arg[32] = "";
+    if (scope) {
+        snprintf(scope_arg, sizeof scope_arg, ", %"PRId64,
+                 scope->binding_key);
+
+        const char *flood = op->od->is_transit_router
+                            ? "flood_remote; " : "";
+        ds_clear(match);
+        ds_put_format(match, "inport == %s && arp.op == 2", op->json_key);
+        ds_clear(actions);
+        ds_put_format(actions, REGBIT_LOOKUP_NEIGHBOR_RESULT
+                      " = lookup_arp(inport, arp.spa, arp.sha%s); "
+                      REGBIT_LOOKUP_NEIGHBOR_IP_RESULT " = 1; %snext;",
+                      scope_arg, flood);
+        ovn_lflow_add(lflows, op->od, S_ROUTER_IN_LOOKUP_NEIGHBOR, 130,
+                      ds_cstr(match), ds_cstr(actions), lflow_ref,
+                      WITH_HINT(&op->nbrp->header_));
+
+        ds_clear(match);
+        ds_put_format(match, "inport == %s && nd_na", op->json_key);
+        ds_clear(actions);
+        ds_put_format(actions, REGBIT_LOOKUP_NEIGHBOR_RESULT
+                      " = lookup_nd(inport, nd.target, nd.tll%s); "
+                      REGBIT_LOOKUP_NEIGHBOR_IP_RESULT " = 1; %snext;",
+                      scope_arg, flood);
+        ovn_lflow_add(lflows, op->od, S_ROUTER_IN_LOOKUP_NEIGHBOR, 130,
+                      ds_cstr(match), ds_cstr(actions), lflow_ref,
+                      WITH_HINT(&op->nbrp->header_));
+
+        ds_put_cstr(match, " && nd.tll == 0");
+        ds_clear(actions);
+        ds_put_format(actions, REGBIT_LOOKUP_NEIGHBOR_RESULT
+                      " = lookup_nd(inport, nd.target, eth.src%s); "
+                      REGBIT_LOOKUP_NEIGHBOR_IP_RESULT " = 1; %snext;",
+                      scope_arg, flood);
+        ovn_lflow_add(lflows, op->od, S_ROUTER_IN_LOOKUP_NEIGHBOR, 135,
+                      ds_cstr(match), ds_cstr(actions), lflow_ref,
+                      WITH_HINT(&op->nbrp->header_));
+
+        if (!learn_from_arp_request) {
+            ds_clear(match);
+            ds_put_format(match, "inport == %s && nd_na && "
+                          "ip6.src == fe80::/10 && ip6.dst == ff00::/8",
+                          op->json_key);
+            ds_clear(actions);
+            ds_put_format(actions, REGBIT_LOOKUP_NEIGHBOR_RESULT
+                          " = lookup_nd(inport, nd.target, nd.tll%s); "
+                          REGBIT_LOOKUP_NEIGHBOR_IP_RESULT
+                          " = lookup_nd_ip(inport, nd.target%s); %snext;",
+                          scope_arg, scope_arg, flood);
+            ovn_lflow_add(lflows, op->od, S_ROUTER_IN_LOOKUP_NEIGHBOR, 140,
+                          ds_cstr(match), ds_cstr(actions), lflow_ref,
+                          WITH_HINT(&op->nbrp->header_));
+
+            ds_put_cstr(match, " && nd.tll == 0");
+            ds_clear(actions);
+            ds_put_format(actions, REGBIT_LOOKUP_NEIGHBOR_RESULT
+                          " = lookup_nd(inport, nd.target, eth.src%s); "
+                          REGBIT_LOOKUP_NEIGHBOR_IP_RESULT
+                          " = lookup_nd_ip(inport, nd.target%s); %snext;",
+                          scope_arg, scope_arg, flood);
+            ovn_lflow_add(lflows, op->od, S_ROUTER_IN_LOOKUP_NEIGHBOR, 145,
+                          ds_cstr(match), ds_cstr(actions), lflow_ref,
+                          WITH_HINT(&op->nbrp->header_));
+        }
+
+        ds_clear(match);
+        ds_put_format(match, "inport == %s && ("
+                      REGBIT_LOOKUP_NEIGHBOR_RESULT " == 1%s)",
+                      op->json_key, learn_from_arp_request ? "" :
+                      " || " REGBIT_LOOKUP_NEIGHBOR_IP_RESULT " == 0");
+        ds_clear(actions);
+        ds_put_format(actions, "mac_cache_use(%"PRId64"); next;",
+                      scope->binding_key);
+        ovn_lflow_add(lflows, op->od, S_ROUTER_IN_LEARN_NEIGHBOR, 110,
+                      ds_cstr(match), ds_cstr(actions), lflow_ref,
+                      WITH_HINT(&op->nbrp->header_));
+    }
 
     /* Check if we need to learn mac-binding from ARP requests. */
     for (size_t i = 0; i < op->lrp_networks.n_ipv4_addrs; i++) {
@@ -15538,12 +15620,13 @@ build_neigh_learning_flows_for_lrouter_port(
                 ds_put_format(match, " && is_chassis_resident(%s)",
                               op->cr_port->json_key);
             }
-            const char *actions_s = REGBIT_LOOKUP_NEIGHBOR_RESULT
-                              " = lookup_arp(inport, arp.spa, arp.sha); "
-                              REGBIT_LOOKUP_NEIGHBOR_IP_RESULT" = 1;"
-                              " next;";
+            ds_clear(actions);
+            ds_put_format(actions, REGBIT_LOOKUP_NEIGHBOR_RESULT
+                          " = lookup_arp(inport, arp.spa, arp.sha%s); "
+                          REGBIT_LOOKUP_NEIGHBOR_IP_RESULT " = 1; next;",
+                          scope_arg);
             ovn_lflow_add(lflows, op->od, S_ROUTER_IN_LOOKUP_NEIGHBOR, 110,
-                          ds_cstr(match), actions_s, lflow_ref,
+                          ds_cstr(match), ds_cstr(actions), lflow_ref,
                           WITH_HINT(&op->nbrp->header_));
         }
         ds_clear(match);
@@ -15558,10 +15641,17 @@ build_neigh_learning_flows_for_lrouter_port(
         }
         ds_clear(actions);
         ds_put_format(actions, REGBIT_LOOKUP_NEIGHBOR_RESULT
-                      " = lookup_arp(inport, arp.spa, arp.sha); %snext;",
-                      learn_from_arp_request ? "" :
-                      REGBIT_LOOKUP_NEIGHBOR_IP_RESULT
-                      " = lookup_arp_ip(inport, arp.spa); ");
+                      " = lookup_arp(inport, arp.spa, arp.sha%s); ",
+                      scope_arg);
+        if (!learn_from_arp_request) {
+            ds_put_format(actions, REGBIT_LOOKUP_NEIGHBOR_IP_RESULT
+                          " = lookup_arp_ip(inport, arp.spa%s); ",
+                          scope_arg);
+        } else if (scope) {
+            /* Do not let the router-wide policy suppress scoped learning. */
+            ds_put_cstr(actions, REGBIT_LOOKUP_NEIGHBOR_IP_RESULT " = 1; ");
+        }
+        ds_put_cstr(actions, "next;");
         ovn_lflow_add(lflows, op->od, S_ROUTER_IN_LOOKUP_NEIGHBOR, 100,
                       ds_cstr(match), ds_cstr(actions), lflow_ref,
                       WITH_HINT(&op->nbrp->header_));
@@ -15589,12 +15679,13 @@ build_neigh_learning_flows_for_lrouter_port(
                 ds_put_format(match, " && is_chassis_resident(%s)",
                               op->cr_port->json_key);
             }
-            const char *actions_s = REGBIT_LOOKUP_NEIGHBOR_RESULT
-                              " = lookup_nd(inport, ip6.src, nd.sll); "
-                              REGBIT_LOOKUP_NEIGHBOR_IP_RESULT" = 1;"
-                              " next;";
+            ds_clear(actions);
+            ds_put_format(actions, REGBIT_LOOKUP_NEIGHBOR_RESULT
+                          " = lookup_nd(inport, ip6.src, nd.sll%s); "
+                          REGBIT_LOOKUP_NEIGHBOR_IP_RESULT " = 1; next;",
+                          scope_arg);
             ovn_lflow_add(lflows, op->od, S_ROUTER_IN_LOOKUP_NEIGHBOR, 110,
-                          ds_cstr(match), actions_s, lflow_ref,
+                          ds_cstr(match), ds_cstr(actions), lflow_ref,
                           WITH_HINT(&op->nbrp->header_));
         }
         ds_clear(match);
@@ -15609,10 +15700,16 @@ build_neigh_learning_flows_for_lrouter_port(
         }
         ds_clear(actions);
         ds_put_format(actions, REGBIT_LOOKUP_NEIGHBOR_RESULT
-                      " = lookup_nd(inport, ip6.src, nd.sll); %snext;",
-                      learn_from_arp_request ? "" :
-                      REGBIT_LOOKUP_NEIGHBOR_IP_RESULT
-                      " = lookup_nd_ip(inport, ip6.src); ");
+                      " = lookup_nd(inport, ip6.src, nd.sll%s); ",
+                      scope_arg);
+        if (!learn_from_arp_request) {
+            ds_put_format(actions, REGBIT_LOOKUP_NEIGHBOR_IP_RESULT
+                          " = lookup_nd_ip(inport, ip6.src%s); ",
+                          scope_arg);
+        } else if (scope) {
+            ds_put_cstr(actions, REGBIT_LOOKUP_NEIGHBOR_IP_RESULT " = 1; ");
+        }
+        ds_put_cstr(actions, "next;");
         ovn_lflow_add(lflows, op->od, S_ROUTER_IN_LOOKUP_NEIGHBOR, 100,
                       ds_cstr(match), ds_cstr(actions), lflow_ref,
                       WITH_HINT(&op->nbrp->header_));
@@ -15624,7 +15721,8 @@ build_neigh_learning_flows_for_lrouter_port(
                              "eth.mcast && "
                              "!is_chassis_resident(%s)", op->json_key,
                              op->cr_port->json_key);
-        ovn_lflow_add(lflows, op->od, S_ROUTER_IN_LOOKUP_NEIGHBOR, 120,
+        ovn_lflow_add(lflows, op->od, S_ROUTER_IN_LOOKUP_NEIGHBOR,
+                      scope ? 150 : 120,
                       ds_cstr(match), REGBIT_LOOKUP_NEIGHBOR_RESULT" = 1; "
                       "next;", lflow_ref, WITH_HINT(&op->nbrp->header_));
     }
@@ -16249,6 +16347,30 @@ build_arp_resolve_flows_for_lrp(struct ovn_port *op,
                                 struct lflow_ref *lflow_ref)
 {
     ovs_assert(op->nbrp);
+    const struct sbrec_mac_binding_scope *scope =
+        op->sb ? op->sb->mac_binding_scope : NULL;
+    if (scope) {
+        ds_clear(match);
+        ds_put_format(match, "outport == %s && "
+                      REGBIT_NEXTHOP_IS_IPV4 " == 1", op->json_key);
+        ds_clear(actions);
+        ds_put_format(actions, "get_arp(outport, " REG_NEXT_HOP_IPV4
+                      ", %"PRId64"); next;", scope->binding_key);
+        ovn_lflow_add(lflows, op->od, S_ROUTER_IN_ARP_RESOLVE, 2,
+                      ds_cstr(match), ds_cstr(actions), lflow_ref,
+                      WITH_HINT(&op->nbrp->header_));
+
+        ds_clear(match);
+        ds_put_format(match, "outport == %s && "
+                      REGBIT_NEXTHOP_IS_IPV4 " == 0", op->json_key);
+        ds_clear(actions);
+        ds_put_format(actions, "get_nd(outport, " REG_NEXT_HOP_IPV6
+                      ", %"PRId64"); next;", scope->binding_key);
+        ovn_lflow_add(lflows, op->od, S_ROUTER_IN_ARP_RESOLVE, 2,
+                      ds_cstr(match), ds_cstr(actions), lflow_ref,
+                      WITH_HINT(&op->nbrp->header_));
+    }
+
     /* This is a logical router port. If next-hop IP address in
      * REG_NEXT_HOP_IPV4/REG_NEXT_HOP_IPV6 matches IP address of this
      * router port, then the packet is intended to eventually be sent
@@ -19214,23 +19336,43 @@ build_lrouter_nat_defrag_and_lb(
                 ovn_lflow_add(lflows, od, S_ROUTER_IN_ARP_RESOLVE, 100,
                               ds_cstr(match), ds_cstr(actions), lflow_ref,
                               WITH_HINT(&nat->header_));
-                if (od->redirect_bridged && nat_entry->is_distributed) {
+                if (od->redirect_bridged &&
+                    vector_len(&od->l3dgw_ports) == 1 &&
+                    nat_entry->is_distributed) {
+                    const struct ovn_port *redirect_port =
+                        vector_get(&od->l3dgw_ports, 0,
+                                   const struct ovn_port *);
                     ds_clear(match);
                     ds_put_format(
                             match,
                             "outport == %s && ip%s.src == %s "
                             "&& is_chassis_resident(\"%s\")",
-                            vector_get(&od->l3dgw_ports, 0,
-                                       const struct ovn_port *)->json_key,
+                            redirect_port->json_key,
                             is_v6 ? "6" : "4", nat->logical_ip,
                             nat->logical_port);
                     ds_clear(actions);
+                    const struct sbrec_mac_binding_scope *scope =
+                        redirect_port->sb
+                        ? redirect_port->sb->mac_binding_scope
+                        : NULL;
                     if (is_v6) {
-                        ds_put_cstr(actions,
-                            "get_nd(outport, " REG_NEXT_HOP_IPV6 "); next;");
+                        if (scope) {
+                            ds_put_format(actions, "get_nd(outport, "
+                                REG_NEXT_HOP_IPV6 ", %"PRId64"); next;",
+                                scope->binding_key);
+                        } else {
+                            ds_put_cstr(actions, "get_nd(outport, "
+                                REG_NEXT_HOP_IPV6 "); next;");
+                        }
                     } else {
-                        ds_put_cstr(actions,
-                            "get_arp(outport, " REG_NEXT_HOP_IPV4 "); next;");
+                        if (scope) {
+                            ds_put_format(actions, "get_arp(outport, "
+                                REG_NEXT_HOP_IPV4 ", %"PRId64"); next;",
+                                scope->binding_key);
+                        } else {
+                            ds_put_cstr(actions, "get_arp(outport, "
+                                REG_NEXT_HOP_IPV4 "); next;");
+                        }
                     }
                     ovn_lflow_add(lflows, od, S_ROUTER_IN_ARP_RESOLVE, 90,
                                   ds_cstr(match), ds_cstr(actions), lflow_ref,
@@ -19633,10 +19775,10 @@ build_lrp_lflows_for_lbnats(struct ovn_port *op,
      * in stage "lr_in_ip_input" but traffic that could have been unSNATed
      * but didn't match any existing session might still end up here.
      *
-     * Priority 2.
+     * Priority 3, above both private and scoped neighbor lookups.
      */
     build_lrouter_drop_own_dest(op, lr_stateful_rec,
-                                S_ROUTER_IN_ARP_RESOLVE, 2, true,
+                                S_ROUTER_IN_ARP_RESOLVE, 3, true,
                                 lflows, op->stateful_lflow_ref);
 
     build_lrouter_ipv4_ip_input_for_lbnats(op, lflows, lr_stateful_rec,
diff --git a/northd/ovn-northd.c b/northd/ovn-northd.c
index b7bf310c2..8cf243d6c 100644
--- a/northd/ovn-northd.c
+++ b/northd/ovn-northd.c
@@ -115,6 +115,11 @@ static const char *rbac_mac_binding_auth[] =
 static const char *rbac_mac_binding_update[] =
     {"logical_port", "ip", "mac", "datapath", "timestamp"};
 
+static const char *rbac_shared_mac_binding_auth[] =
+    {""};
+static const char *rbac_shared_mac_binding_update[] =
+    {"scope", "ip", "mac", "timestamp"};
+
 static const char *rbac_svc_monitor_auth[] =
     {"chassis_name"};
 static const char *rbac_svc_monitor_auth_update[] =
@@ -198,6 +203,14 @@ static struct rbac_perm_cfg {
         .update = rbac_mac_binding_update,
         .n_update = ARRAY_SIZE(rbac_mac_binding_update),
         .row = NULL
+    },{
+        .table = "Shared_MAC_Binding",
+        .auth = rbac_shared_mac_binding_auth,
+        .n_auth = ARRAY_SIZE(rbac_shared_mac_binding_auth),
+        .insdel = true,
+        .update = rbac_shared_mac_binding_update,
+        .n_update = ARRAY_SIZE(rbac_shared_mac_binding_update),
+        .row = NULL
     },{
         .table = "Service_Monitor",
         .auth = rbac_svc_monitor_auth,
diff --git a/ovn-nb.ovsschema b/ovn-nb.ovsschema
index a29245115..9a397f59a 100644
--- a/ovn-nb.ovsschema
+++ b/ovn-nb.ovsschema
@@ -1,7 +1,7 @@
 {
     "name": "OVN_Northbound",
-    "version": "7.19.0",
-    "cksum": "2631744256 45474",
+    "version": "7.20.0",
+    "cksum": "2786460320 46440",
     "tables": {
         "NB_Global": {
             "columns": {
@@ -583,6 +583,12 @@
                                      "refType": "strong"},
                              "min": 0,
                              "max": 1}},
+                "mac_binding_scope": {
+                    "type": {"key": {"type": "uuid",
+                                     "refTable": "MAC_Binding_Scope",
+                                     "refType": "weak"},
+                             "min": 0,
+                             "max": 1}},
                 "options": {
                     "type": {"key": "string",
                              "value": "string",
@@ -861,7 +867,21 @@
                 "mac": {"type": "string"},
                 "override_dynamic_mac": {"type": "boolean"}},
             "indexes": [["logical_port", "ip"]],
-             "isRoot": true},
+            "isRoot": true},
+        "MAC_Binding_Scope": {
+            "columns": {
+                "name": {"type": "string"},
+                "always_learn_from_arp_request": {
+                    "type": {"key": "boolean", "min": 0, "max": 1}},
+                "disable_garp_rarp": {
+                    "type": {"key": "boolean", "min": 0, "max": 1}},
+                "mac_binding_age_threshold": {
+                    "type": {"key": "string", "min": 0, "max": 1}},
+                "external_ids": {
+                    "type": {"key": "string", "value": "string",
+                             "min": 0, "max": "unlimited"}}},
+            "indexes": [["name"]],
+            "isRoot": true},
         "Chassis_Template_Var": {
             "columns": {
                 "chassis": {"type": "string"},
diff --git a/ovn-nb.xml b/ovn-nb.xml
index d3f3cc197..2027f1123 100644
--- a/ovn-nb.xml
+++ b/ovn-nb.xml
@@ -4218,6 +4218,23 @@ or
       to <ref table="DHCP_Relay"/> table.
     </column>
 
+    <column name="mac_binding_scope">
+      <p>
+        Optional.  Associates this port with a <ref
+        table="MAC_Binding_Scope"/>.  Router ports in the same scope share
+        one set of dynamically learned IP-to-MAC bindings.
+      </p>
+
+      <p>
+        All members of a scope must be hosted by the same gateway chassis (or
+        equivalent HA chassis group) and connect to the same physical L2
+        broadcast domain.  <code>ovn-northd</code> ignores the complete scope
+        if these requirements are not met.  The CMS is responsible for
+        delivering ARP and IPv6 neighbor discovery traffic to at least one
+        member of a valid scope.
+      </p>
+    </column>
+
     <column name="options" key="dhcpv4_primary_ip">
       <p>
         Optional.  Selects which of the logical router port's IPv4
@@ -6802,6 +6819,64 @@ or
     </group>
   </table>
 
+  <table name="MAC_Binding_Scope" title="Shared dynamic MAC bindings">
+    <p>
+      Each row defines a group of logical router ports that share one set of
+      dynamically learned IP-to-MAC bindings.  Membership is configured with
+      <ref table="Logical_Router_Port" column="mac_binding_scope"/>.
+      Bindings learned on any member are available to every member.
+    </p>
+
+    <column name="name">
+      A human-readable name that must be unique within the database.
+    </column>
+
+    <column name="always_learn_from_arp_request">
+      <p>
+        Controls whether members always learn from IPv4 ARP requests and IPv6
+        Neighbor Solicitation packets.  If unset, the default is
+        <code>true</code>.
+      </p>
+
+      <p>
+        When <code>false</code>, a request is learned only if a binding for
+        the source IP already exists with a different MAC, or if the request
+        targets an IP address owned by the receiving router.  This has the
+        same meaning as the Logical_Router
+        <ref table="Logical_Router" column="options"
+        key="always_learn_from_arp_request"/> option, but applies to the
+        complete scope and takes precedence for scope members.
+      </p>
+    </column>
+
+    <column name="disable_garp_rarp">
+      <p>
+        When <code>true</code>, GARP and RARP announcements are not sent by VIF
+        peer ports of member logical routers.  If unset, the default is
+        <code>false</code>.  This has the same meaning as the Logical_Router
+        <ref table="Logical_Router" column="options" key="disable_garp_rarp"/>
+        option, but applies to the complete scope and takes precedence for
+        scope members.
+      </p>
+    </column>
+
+    <column name="mac_binding_age_threshold">
+      <p>
+        Optional aging threshold for bindings in this scope.  The syntax and
+        behavior are the same as the Logical_Router
+        <ref table="Logical_Router" column="options"
+        key="mac_binding_age_threshold"/> option.  This value applies to the
+        complete scope and takes precedence for scope members.
+      </p>
+    </column>
+
+    <group title="Common Columns">
+      <column name="external_ids">
+        See <em>External IDs</em> at the beginning of this document.
+      </column>
+    </group>
+  </table>
+
   <table name="Chassis_Template_Var">
     <p>
       One record per chassis, each containing a map, <code>variables</code>,
diff --git a/ovn-sb.ovsschema b/ovn-sb.ovsschema
index e49f1b6c5..b62872ae1 100644
--- a/ovn-sb.ovsschema
+++ b/ovn-sb.ovsschema
@@ -1,7 +1,7 @@
 {
     "name": "OVN_Southbound",
-    "version": "21.13.0",
-    "cksum": "2401729496 36997",
+    "version": "21.14.0",
+    "cksum": "3047594472 38484",
     "tables": {
         "SB_Global": {
             "columns": {
@@ -231,6 +231,12 @@
                                      "refType": "strong"},
                              "min": 0,
                              "max": 1}},
+                "mac_binding_scope": {
+                    "type": {"key": {"type": "uuid",
+                                     "refTable": "MAC_Binding_Scope",
+                                     "refType": "weak"},
+                             "min": 0,
+                             "max": 1}},
                 "options": {
                      "type": {"key": "string",
                               "value": "string",
@@ -307,6 +313,32 @@
                                               "refTable": 
"Datapath_Binding"}}}},
             "indexes": [["logical_port", "ip"]],
             "isRoot": true},
+        "MAC_Binding_Scope": {
+            "columns": {
+                "name": {"type": "string"},
+                "binding_key": {
+                    "type": {"key": {"type": "integer",
+                                     "minInteger": 1,
+                                     "maxInteger": 16777215}}},
+                "options": {
+                    "type": {"key": "string", "value": "string",
+                             "min": 0, "max": "unlimited"}},
+                "external_ids": {
+                    "type": {"key": "string", "value": "string",
+                             "min": 0, "max": "unlimited"}}},
+            "indexes": [["name"], ["binding_key"]],
+            "isRoot": true},
+        "Shared_MAC_Binding": {
+            "columns": {
+                "scope": {
+                    "type": {"key": {"type": "uuid",
+                                     "refTable": "MAC_Binding_Scope",
+                                     "refType": "strong"}}},
+                "ip": {"type": "string"},
+                "mac": {"type": "string"},
+                "timestamp": {"type": {"key": "integer"}}},
+            "indexes": [["scope", "ip"]],
+            "isRoot": true},
         "DHCP_Options": {
             "columns": {
                 "name": {"type": "string"},
diff --git a/ovn-sb.xml b/ovn-sb.xml
index f9e24dc8c..98cc01ec5 100644
--- a/ovn-sb.xml
+++ b/ovn-sb.xml
@@ -3561,6 +3561,17 @@ tcp.flags = RST;
         logical port ID.
       </column>
 
+      <column name="mac_binding_scope">
+        <p>
+          For a logical router port that participates in shared dynamic MAC
+          binding state, this references the corresponding <ref
+          table="MAC_Binding_Scope"/>.  It is populated by
+          <code>ovn-northd</code> only after validating that all members are
+          co-located on the same physical L2 broadcast domain and all relevant
+          chassis support the feature.
+        </p>
+      </column>
+
       <column name="encap">
         Points to preferred encapsulation configuration to transmit
         logical dataplane packets to this chassis. The entry is a reference to
@@ -4451,6 +4462,77 @@ tcp.flags = RST;
     </column>
   </table>
 
+  <table name="MAC_Binding_Scope" title="Shared MAC binding scopes">
+    <p>
+      Each row represents a validated group of logical router ports that use
+      the same dynamically learned IP-to-MAC bindings.  <code>ovn-northd</code>
+      creates and maintains these rows from the corresponding
+      <ref table="MAC_Binding_Scope" db="OVN_Northbound"/> records.
+    </p>
+
+    <column name="name">
+      The name copied from the corresponding northbound scope.
+    </column>
+
+    <column name="binding_key">
+      <p>
+        A locally significant identifier used by <code>ovn-controller</code>
+        to represent this scope in the MAC binding OpenFlow lookup tables.
+        The key is unique among scope rows.
+      </p>
+    </column>
+
+    <column name="options" key="always_learn_from_arp_request">
+      The scope-wide value copied from the corresponding northbound scope.
+      An empty value means <code>true</code>.
+    </column>
+
+    <column name="options" key="disable_garp_rarp">
+      The scope-wide value copied from the corresponding northbound scope.
+      An empty value means <code>false</code>.
+    </column>
+
+    <column name="options" key="mac_binding_age_threshold">
+      The optional scope-wide MAC binding aging threshold copied from the
+      corresponding northbound scope.
+    </column>
+
+    <group title="Common Columns">
+      <column name="external_ids">
+        <p>
+          <code>ovn-northd</code> stores the UUID of the corresponding
+          northbound <ref table="MAC_Binding_Scope" db="OVN_Northbound"/>
+          in the <code>ovn-nb-id</code> key.
+        </p>
+      </column>
+    </group>
+  </table>
+
+  <table name="Shared_MAC_Binding" title="Shared IP to MAC bindings">
+    <p>
+      Each row specifies a dynamically learned binding from an IP address to
+      an Ethernet address that is valid for every router port in the referenced
+      <ref table="MAC_Binding_Scope"/>.  This table expresses the functional
+      relationship <code>Shared_MAC_Binding(scope, ip) = mac</code>.
+    </p>
+
+    <column name="scope">
+      The shared MAC binding scope in which this binding was learned.
+    </column>
+
+    <column name="ip">
+      The bound IPv4 or IPv6 address.
+    </column>
+
+    <column name="mac">
+      The Ethernet address to which the IP address is bound.
+    </column>
+
+    <column name="timestamp">
+      The timestamp in milliseconds when the binding was added or updated.
+    </column>
+  </table>
+
   <table name="DHCP_Options" title="DHCP Options supported by native OVN DHCP">
     <p>
       Each row in this table stores the DHCP Options supported by native OVN
diff --git a/tests/ovn-inc-proc-graph-dump.at b/tests/ovn-inc-proc-graph-dump.at
index d6f93c889..e77ef037f 100644
--- a/tests/ovn-inc-proc-graph-dump.at
+++ b/tests/ovn-inc-proc-graph-dump.at
@@ -174,6 +174,17 @@ digraph "Incremental-Processing-Engine" {
        routes -> group_ecmp_route 
[[label="group_ecmp_route_routes_change_handler"]];
        learned_route_sync -> group_ecmp_route 
[[label="group_ecmp_route_learned_route_change_handler"]];
        dynamic_routes -> group_ecmp_route 
[[label="group_ecmp_route_dynamic_routes_change_handler"]];
+       NB_mac_binding_scope [[style=filled, shape=box, fillcolor=white, 
label="NB_mac_binding_scope"]];
+       SB_mac_binding_scope [[style=filled, shape=box, fillcolor=white, 
label="SB_mac_binding_scope"]];
+       SB_shared_mac_binding [[style=filled, shape=box, fillcolor=white, 
label="SB_shared_mac_binding"]];
+       mac_binding_scope [[style=filled, shape=box, fillcolor=white, 
label="mac_binding_scope"]];
+       northd -> mac_binding_scope [[label=""]];
+       global_config -> mac_binding_scope [[label=""]];
+       NB_mac_binding_scope -> mac_binding_scope [[label=""]];
+       SB_mac_binding_scope -> mac_binding_scope [[label=""]];
+       SB_shared_mac_binding -> mac_binding_scope 
[[label="engine_noop_handler"]];
+       SB_mac_binding -> mac_binding_scope [[label="engine_noop_handler"]];
+       SB_port_binding -> mac_binding_scope [[label="engine_noop_handler"]];
        ls_stateful [[style=filled, shape=box, fillcolor=white, 
label="ls_stateful"]];
        northd -> ls_stateful [[label="ls_stateful_northd_handler"]];
        port_group -> ls_stateful [[label="ls_stateful_port_group_handler"]];
@@ -194,6 +205,7 @@ digraph "Incremental-Processing-Engine" {
        sampling_app -> lflow [[label=""]];
        datapath_sync -> lflow [[label="engine_noop_handler"]];
        northd -> lflow [[label="lflow_northd_handler"]];
+       mac_binding_scope -> lflow [[label=""]];
        port_group -> lflow [[label="engine_noop_handler"]];
        lr_stateful -> lflow [[label="lflow_lr_stateful_handler"]];
        ls_stateful -> lflow [[label="lflow_ls_stateful_handler"]];
@@ -210,6 +222,9 @@ digraph "Incremental-Processing-Engine" {
        mac_binding_aging_waker [[style=filled, shape=box, fillcolor=white, 
label="mac_binding_aging_waker"]];
        mac_binding_aging [[style=filled, shape=box, fillcolor=white, 
label="mac_binding_aging"]];
        SB_mac_binding -> mac_binding_aging [[label=""]];
+       SB_shared_mac_binding -> mac_binding_aging [[label=""]];
+       SB_mac_binding_scope -> mac_binding_aging [[label=""]];
+       mac_binding_scope -> mac_binding_aging [[label=""]];
        northd -> mac_binding_aging [[label=""]];
        mac_binding_aging_waker -> mac_binding_aging [[label=""]];
        global_config -> mac_binding_aging 
[[label="node_global_config_handler"]];
diff --git a/tests/ovn-northd.at b/tests/ovn-northd.at
index d9682f78e..db2cb9b92 100644
--- a/tests/ovn-northd.at
+++ b/tests/ovn-northd.at
@@ -9935,6 +9935,154 @@ OVN_CLEANUP_NORTHD
 AT_CLEANUP
 ])
 
+OVN_FOR_EACH_NORTHD_NO_HV([
+AT_SETUP([LR shared MAC binding scopes])
+ovn_start
+
+check ovn-sbctl chassis-add gw geneve 127.0.0.1
+
+check ovn-nbctl \
+    -- ls-add ext0 \
+    -- lsp-add-localnet-port ext0 ln0 physnet \
+    -- lr-add lr0 \
+    -- lrp-add lr0 rp0 00:00:00:00:00:01 192.0.2.1/24 fd00::1/64 \
+    -- lsp-add-router-port ext0 ext0-rp0 rp0 \
+    -- ls-add ext1 \
+    -- lsp-add-localnet-port ext1 ln1 physnet \
+    -- lr-add lr1 \
+    -- lrp-add lr1 rp1 00:00:00:00:00:02 192.0.2.2/24 fd00::2/64 \
+    -- lsp-add-router-port ext1 ext1-rp1 rp1
+check ovn-nbctl lrp-set-gateway-chassis rp0 gw
+check ovn-nbctl lrp-set-gateway-chassis rp1 gw
+
+scope=$(ovn-nbctl create MAC_Binding_Scope name=scope0 \
+    always_learn_from_arp_request=false disable_garp_rarp=true \
+    mac_binding_age_threshold=300)
+check ovn-nbctl set Logical_Router_Port rp0 mac_binding_scope=$scope \
+    -- set Logical_Router_Port rp1 mac_binding_scope=$scope
+check ovn-nbctl --wait=sb sync
+
+dnl The scope stays inactive until every local chassis supports it.
+check_row_count sb:MAC_Binding_Scope 0
+
+dnl Restoring support during a full recompute must re-enable the feature,
+dnl not retain the previously unsupported state.
+check as northd ovn-appctl -t ovn-northd pause
+check ovn-sbctl set Chassis gw other_config:shared-mac-binding=true
+check as northd ovn-appctl -t ovn-northd resume
+check ovn-nbctl --wait=sb sync
+check_row_count sb:MAC_Binding_Scope 1 name=scope0
+
+sb_scope=$(fetch_column sb:MAC_Binding_Scope _uuid name=scope0)
+scope_key=$(fetch_column sb:MAC_Binding_Scope binding_key name=scope0)
+check test "$scope_key" -gt 0
+check_column "$sb_scope" sb:Port_Binding mac_binding_scope \
+    logical_port=rp0
+check_column "$sb_scope" sb:Port_Binding mac_binding_scope \
+    logical_port=rp1
+check_column "$sb_scope" sb:Port_Binding mac_binding_scope \
+    logical_port=cr-rp0
+check_column "$sb_scope" sb:Port_Binding mac_binding_scope \
+    logical_port=cr-rp1
+check_row_count sb:MAC_Binding_Scope 1 name=scope0 \
+    options:always_learn_from_arp_request=false \
+    options:disable_garp_rarp=true options:mac_binding_age_threshold=300
+
+dnl Revalidating an unchanged active scope must not force lflow recompute.
+check as northd ovn-appctl -t ovn-northd inc-engine/clear-stats
+check ovn-nbctl --wait=sb lb-add lb0 198.51.100.1 198.51.100.2
+check_engine_stats mac_binding_scope recompute nocompute
+check_engine_stats lflow norecompute compute
+
+ovn-sbctl dump-flows lr0 > lr0flows
+AT_CAPTURE_FILE([lr0flows])
+AT_CHECK([grep -Fq "lookup_arp(inport, arp.spa, arp.sha, $scope_key)" \
+    lr0flows])
+AT_CHECK([grep -Fq "get_arp(outport, reg0, $scope_key)" lr0flows])
+AT_CHECK([grep -Fq "mac_cache_use($scope_key)" lr0flows])
+
+dnl Scope policy takes precedence over the router policy in both directions.
+dnl Trace cold requests and replies, including NAs without a TLL option.
+check ovn-nbctl --wait=sb set Logical_Router lr0 \
+    options:always_learn_from_arp_request=false \
+    -- set MAC_Binding_Scope $scope always_learn_from_arp_request=true
+arp='inport == "rp0" && eth.src == 00:00:00:00:00:fe && eth.dst == 
00:00:00:00:00:01 && arp.spa == 192.0.2.254 && arp.tpa == 192.0.2.253 && 
arp.sha == 00:00:00:00:00:fe && arp.tha == 00:00:00:00:00:01'
+nd='inport == "rp0" && eth.src == 00:00:00:00:00:fe && eth.dst == 
00:00:00:00:00:01 && ip6.src == fd00::fe && ip6.dst == fd00::1 && ip.ttl == 255 
&& nd.target == fd00::fd'
+for op in 1 2; do
+    AT_CHECK([ovn_trace --detailed lr0 "$arp && arp.op == $op" > trace])
+    AT_CHECK([grep -q 'put_arp(inport, arp.spa, arp.sha)' trace])
+done
+for packet in 'nd_ns && nd.sll == 00:00:00:00:00:fe' \
+              'nd_na && nd.tll == 00:00:00:00:00:fe' \
+              'nd_na && nd.tll == 00:00:00:00:00:00'; do
+    AT_CHECK([ovn_trace --detailed lr0 "$nd && $packet" > trace])
+    AT_CHECK([grep -q 'put_nd' trace])
+done
+check ovn-nbctl --wait=sb set Logical_Router lr0 \
+    options:always_learn_from_arp_request=true \
+    -- set MAC_Binding_Scope $scope always_learn_from_arp_request=false
+AT_CHECK([ovn_trace --detailed lr0 "$arp && arp.op == 1" > trace])
+AT_CHECK([grep -q 'put_arp' trace], [1])
+AT_CHECK([ovn_trace --detailed lr0 \
+    "$nd && nd_ns && nd.sll == 00:00:00:00:00:fe" > trace])
+AT_CHECK([grep -q 'put_nd' trace], [1])
+AT_CAPTURE_FILE([trace])
+
+dnl Router-owned SNAT addresses must be dropped before either the IPv4 or
+dnl IPv6 scoped neighbor lookup, including unmatched inbound connections.
+check ovn-nbctl lr-nat-add lr0 snat 192.0.2.1 10.0.0.0/24
+check ovn-nbctl lr-nat-add lr0 snat fd00::1 fd01::/64
+check ovn-nbctl --wait=sb sync
+AT_CHECK([ovn-sbctl dump-flows lr0 | grep lr_in_arp_resolve | \
+    grep 'priority=3 ' | ovn_strip_lflows], [0], [dnl
+  table=??(lr_in_arp_resolve  ), priority=3    , match=(ip4.dst == 
{192.0.2.1}), action=(drop;)
+  table=??(lr_in_arp_resolve  ), priority=3    , match=(ip6.dst == {fd00::1}), 
action=(drop;)
+])
+
+dnl Reconciliation preserves the OpenFlow namespace key.
+check ovn-nbctl --wait=sb set MAC_Binding_Scope $scope name=scope1
+check_column "$scope_key" sb:MAC_Binding_Scope binding_key name=scope1
+
+dnl Private rows for active members are stale and must be removed.
+dp0=$(fetch_column Datapath_Binding _uuid external_ids:name=lr0)
+check_uuid ovn-sbctl create MAC_Binding logical_port=rp0 \
+    ip=192.0.2.254 mac="00\:00\:00\:00\:00\:fe" datapath=$dp0 \
+    timestamp=0
+check_row_count MAC_Binding 1 logical_port=rp0
+check ovn-nbctl --wait=sb set MAC_Binding_Scope $scope \
+    external_ids:reconcile=true
+check_row_count MAC_Binding 0 logical_port=rp0
+
+dnl A shared row survives reconciliation but is removed with its scope.
+check ovn-nbctl --wait=sb clear MAC_Binding_Scope $scope \
+    mac_binding_age_threshold
+AT_CHECK([ovn-sbctl --if-exists get MAC_Binding_Scope $sb_scope \
+    options:mac_binding_age_threshold], [0], [
+])
+check_uuid ovn-sbctl create Shared_MAC_Binding scope=$sb_scope \
+    ip=192.0.2.254 mac="00\:00\:00\:00\:00\:fe" timestamp=0
+check ovn-nbctl --wait=sb set MAC_Binding_Scope $scope \
+    external_ids:reconcile=false
+check_row_count Shared_MAC_Binding 1 ip=192.0.2.254
+
+dnl A different localnet mapping invalidates the complete scope.
+check ovn-nbctl --wait=sb set Logical_Switch_Port ln1 \
+    options:network_name=other
+check_row_count sb:MAC_Binding_Scope 0
+check_row_count Shared_MAC_Binding 0
+check_column "" sb:Port_Binding mac_binding_scope logical_port=rp0
+check_column "" sb:Port_Binding mac_binding_scope logical_port=rp1
+
+dnl Invalid scope-wide aging policy also keeps the scope inactive.
+check ovn-nbctl set Logical_Switch_Port ln1 options:network_name=physnet
+check ovn-nbctl --wait=sb set MAC_Binding_Scope $scope \
+    mac_binding_age_threshold=invalid
+check_row_count sb:MAC_Binding_Scope 0
+
+OVN_CLEANUP_NORTHD
+AT_CLEANUP
+])
+
 OVN_FOR_EACH_NORTHD_NO_HV_PARALLELIZATION([
 AT_SETUP([LS default ACL drop])
 AT_KEYWORDS([acl])
@@ -11125,6 +11273,9 @@ AT_SETUP([check fip flows with redirect-type bridged])
 AT_KEYWORDS([fip-redirect-type-bridged])
 ovn_start
 
+check ovn-sbctl chassis-add hv1 geneve 127.0.0.1 \
+    -- set Chassis hv1 other_config:shared-mac-binding=true
+
 check ovn-nbctl lr-add R1
 check ovn-nbctl lrp-add R1 R1-S0 02:ac:10:01:00:01 10.0.0.1/24 1000::a/64
 check ovn-nbctl lrp-add R1 R1-PUB 02:ac:20:01:01:01 172.16.0.1/24 3000::a/64
@@ -11133,7 +11284,16 @@ check ovn-nbctl lrp-set-gateway-chassis R1-PUB hv1 20
 check ovn-nbctl ls-add S0
 check ovn-nbctl lsp-add-router-port S0 S0-R1 R1-S0
 check ovn-nbctl lsp-add S0 S0-P0
-check ovn-nbctl lsp-set-addresses S0-P0 "50:54:00:00:00:03 10.0.0.3 1000::3"
+check ovn-nbctl lsp-set-addresses S0-P0 \
+    "50:54:00:00:00:03 10.0.0.3 10.0.0.4 1000::3"
+
+check ovn-nbctl ls-add PUB
+check ovn-nbctl lsp-add-router-port PUB PUB-R1 R1-PUB
+check ovn-nbctl lsp-add-localnet-port PUB ln-pub physnet1
+
+scope1=$(ovn-nbctl create MAC_Binding_Scope name=scope1)
+check ovn-nbctl set Logical_Router_Port R1-PUB \
+    mac_binding_scope=$scope1
 
 check ovn-nbctl lr-nat-add R1 dnat_and_snat 172.16.0.110 10.0.0.3 S0-P0 
30:54:00:00:00:03
 check ovn-nbctl lr-nat-add R1 dnat_and_snat 3000::c 1000::3 S0-P0 
40:54:00:00:00:03
@@ -11147,9 +11307,36 @@ check ovn-nbctl --wait=sb set logical_router_port 
R1-PUB options:redirect-type=b
 ovn-sbctl dump-flows R1 > R1flows
 AT_CAPTURE_FILE([R1flows])
 
-AT_CHECK([grep "lr_in_arp_resolve" R1flows | grep priority=90 | 
ovn_strip_lflows], [0], [dnl
-  table=??(lr_in_arp_resolve  ), priority=90   , match=(outport == "R1-PUB" && 
ip4.src == 10.0.0.3 && is_chassis_resident("S0-P0")), action=(get_arp(outport, 
reg0); next;)
-  table=??(lr_in_arp_resolve  ), priority=90   , match=(outport == "R1-PUB" && 
ip6.src == 1000::3 && is_chassis_resident("S0-P0")), action=(get_nd(outport, 
xxreg0); next;)
+AT_CHECK([grep "lr_in_arp_resolve" R1flows | grep priority=90 | wc -l],
+    [0], [2
+])
+dnl The bridged redirect flows use the single DGP's MAC binding namespace.
+redirect_port=$(grep "lr_in_arp_resolve" R1flows | grep priority=90 | \
+    head -1 | cut -d '"' -f2)
+redirect_scope=$(fetch_column sb:Port_Binding mac_binding_scope \
+    logical_port=$redirect_port)
+redirect_scope_key=$(ovn-sbctl get MAC_Binding_Scope $redirect_scope \
+    binding_key)
+AT_CHECK_UNQUOTED([grep "lr_in_arp_resolve" R1flows | grep priority=90 | \
+    grep -F ", $redirect_scope_key); next;" | wc -l], [0], [2
+])
+
+dnl Bridged redirection is ignored when the router has multiple DGPs.
+check ovn-nbctl lrp-add R1 R1-PUB2 02:ac:20:01:02:01 172.17.0.1/24
+check ovn-nbctl lrp-set-gateway-chassis R1-PUB2 hv1 20
+check ovn-nbctl ls-add PUB2
+check ovn-nbctl lsp-add-router-port PUB2 PUB2-R1 R1-PUB2
+check ovn-nbctl lsp-add-localnet-port PUB2 ln-pub2 physnet2
+scope2=$(ovn-nbctl create MAC_Binding_Scope name=scope2)
+check ovn-nbctl set Logical_Router_Port R1-PUB2 \
+    mac_binding_scope=$scope2
+check ovn-nbctl --gateway-port=R1-PUB2 lr-nat-add R1 dnat_and_snat \
+    172.17.0.110 10.0.0.4 S0-P0 31:54:00:00:00:03
+check ovn-nbctl --wait=sb sync
+
+ovn-sbctl dump-flows R1 > R1flows
+AT_CHECK([grep "lr_in_arp_resolve" R1flows | grep priority=90 | sort],
+    [0], [dnl
 ])
 
 OVN_CLEANUP_NORTHD
diff --git a/tests/ovn.at b/tests/ovn.at
index c080eeb20..ad503faee 100644
--- a/tests/ovn.at
+++ b/tests/ovn.at
@@ -31843,7 +31843,7 @@ if test X"$1" = X"DGP"; then
     inport=reg14
     outport=reg15
 else
-    prio=2
+    prio=3
 fi
 AT_CHECK([as hv1 ovs-ofctl dump-flows br-int | grep -E "table=$arp_resolve, 
n_packets=1,.* 
priority=$prio,ip,$inport.*$outport.*metadata=0x${sw_key},nw_dst=10.0.1.1 
actions=drop" -c], [0], [dnl
 1
diff --git a/utilities/ovn-trace.c b/utilities/ovn-trace.c
index 8901354cb..c5c41910f 100644
--- a/utilities/ovn-trace.c
+++ b/utilities/ovn-trace.c
@@ -480,7 +480,7 @@ struct ovntrace_flow {
 
 struct ovntrace_mac_binding {
     struct hmap_node node;
-    uint16_t port_key;
+    uint32_t port_key;
     struct in6_addr ip;
     struct eth_addr mac;
 };
@@ -492,7 +492,7 @@ struct ovntrace_fdb {
 };
 
 static inline uint32_t
-hash_mac_binding(uint16_t port_key, const struct in6_addr *ip)
+hash_mac_binding(uint32_t port_key, const struct in6_addr *ip)
 {
     return hash_bytes(ip, sizeof *ip, port_key);
 }
@@ -506,6 +506,9 @@ hash_fdb(const struct eth_addr *mac)
 /* Every ovntrace_datapath, by southbound Datapath_Binding record UUID. */
 static struct hmap datapaths;
 
+/* Shared MAC bindings, indexed by scope binding key and IP address. */
+static struct hmap shared_mac_bindings;
+
 /* Every ovntrace_port, by name. */
 static struct shash ports;
 
@@ -642,7 +645,7 @@ ovntrace_mcgroup_find_by_name(const struct 
ovntrace_datapath *dp,
 
 static const struct ovntrace_mac_binding *
 ovntrace_mac_binding_find(const struct ovntrace_datapath *dp,
-                          uint16_t port_key, const struct in6_addr *ip)
+                          uint32_t port_key, const struct in6_addr *ip)
 {
     const struct ovntrace_mac_binding *bind;
     HMAP_FOR_EACH_WITH_HASH (bind, node, hash_mac_binding(port_key, ip),
@@ -656,7 +659,7 @@ ovntrace_mac_binding_find(const struct ovntrace_datapath 
*dp,
 
 static const struct ovntrace_mac_binding *
 ovntrace_mac_binding_find_mac_ip(const struct ovntrace_datapath *dp,
-                                 uint16_t port_key, const struct in6_addr *ip,
+                                 uint32_t port_key, const struct in6_addr *ip,
                                  struct eth_addr *mac)
 {
     const struct ovntrace_mac_binding *bind;
@@ -670,6 +673,20 @@ ovntrace_mac_binding_find_mac_ip(const struct 
ovntrace_datapath *dp,
     return NULL;
 }
 
+static const struct ovntrace_mac_binding *
+ovntrace_shared_mac_binding_find(uint32_t scope,
+                                 const struct in6_addr *ip)
+{
+    const struct ovntrace_mac_binding *bind;
+    HMAP_FOR_EACH_WITH_HASH (bind, node, hash_mac_binding(scope, ip),
+                             &shared_mac_bindings) {
+        if (bind->port_key == scope && ipv6_addr_equals(ip, &bind->ip)) {
+            return bind;
+        }
+    }
+    return NULL;
+}
+
 static const struct ovntrace_fdb *
 ovntrace_fdb_find(const struct ovntrace_datapath *dp,
                   const struct eth_addr *mac)
@@ -1166,6 +1183,36 @@ read_mac_bindings(void)
     }
 }
 
+static void
+read_shared_mac_bindings(void)
+{
+    hmap_init(&shared_mac_bindings);
+
+    const struct sbrec_shared_mac_binding *sbmb;
+    SBREC_SHARED_MAC_BINDING_FOR_EACH (sbmb, ovnsb_idl) {
+        struct in6_addr ip6;
+        if (!ip46_parse(sbmb->ip, &ip6)) {
+            VLOG_WARN_RL(&rl, "%s: bad shared MAC binding IP address",
+                         sbmb->ip);
+            continue;
+        }
+
+        struct eth_addr mac;
+        if (!eth_addr_from_string(sbmb->mac, &mac)) {
+            VLOG_WARN_RL(&rl, "%s: bad shared MAC binding Ethernet address",
+                         sbmb->mac);
+            continue;
+        }
+
+        struct ovntrace_mac_binding *binding = xmalloc(sizeof *binding);
+        binding->port_key = sbmb->scope->binding_key;
+        binding->ip = ip6;
+        binding->mac = mac;
+        hmap_insert(&shared_mac_bindings, &binding->node,
+                    hash_mac_binding(binding->port_key, &ip6));
+    }
+}
+
 static void
 read_fdbs(void)
 {
@@ -1201,6 +1248,7 @@ read_db(void)
     read_gen_opts();
     read_flows();
     read_mac_bindings();
+    read_shared_mac_bindings();
     read_fdbs();
 }
 
@@ -2142,8 +2190,12 @@ execute_get_mac_bind(const struct ovnact_get_mac_bind 
*bind,
                           ? in6_addr_mapped_ipv4(ip_sv.ipv4)
                           : ip_sv.ipv6);
 
-    const struct ovntrace_mac_binding *binding
-        = ovntrace_mac_binding_find(dp, port_key, &ip);
+    const struct ovntrace_mac_binding *binding = bind->scope
+        ? ovntrace_shared_mac_binding_find(bind->scope, &ip)
+        : NULL;
+    if (!binding) {
+        binding = ovntrace_mac_binding_find(dp, port_key, &ip);
+    }
 
     uflow->dl_dst = binding ? binding->mac : eth_addr_zero;
     if (binding) {
@@ -2185,8 +2237,22 @@ execute_lookup_mac_bind(const struct 
ovnact_lookup_mac_bind *bind,
     union mf_subvalue mac_sv;
     mf_read_subfield(&mac_sf, uflow, &mac_sv);
 
-    const struct ovntrace_mac_binding *binding
-        = ovntrace_mac_binding_find_mac_ip(dp, port_key, &ip, &mac_sv.mac);
+    const struct ovntrace_mac_binding *binding = NULL;
+    bool shared_binding_found = false;
+    if (bind->scope) {
+        const struct ovntrace_mac_binding *shared_binding =
+            ovntrace_shared_mac_binding_find(bind->scope, &ip);
+        if (shared_binding) {
+            shared_binding_found = true;
+            if (eth_addr_equals(shared_binding->mac, mac_sv.mac)) {
+                binding = shared_binding;
+            }
+        }
+    }
+    if (!shared_binding_found) {
+        binding = ovntrace_mac_binding_find_mac_ip(dp, port_key, &ip,
+                                                   &mac_sv.mac);
+    }
 
     struct mf_subfield dst = expr_resolve_field(&bind->dst);
     uint8_t val = 0;
@@ -2224,8 +2290,12 @@ execute_lookup_mac_bind_ip(const struct 
ovnact_lookup_mac_bind_ip *bind,
                           ? in6_addr_mapped_ipv4(ip_sv.ipv4)
                           : ip_sv.ipv6);
 
-    const struct ovntrace_mac_binding *binding
-        = ovntrace_mac_binding_find_mac_ip(dp, port_key, &ip, NULL);
+    const struct ovntrace_mac_binding *binding = bind->scope
+        ? ovntrace_shared_mac_binding_find(bind->scope, &ip)
+        : NULL;
+    if (!binding) {
+        binding = ovntrace_mac_binding_find_mac_ip(dp, port_key, &ip, NULL);
+    }
 
     struct mf_subfield dst = expr_resolve_field(&bind->dst);
     uint8_t val = 0;
-- 
2.56.0

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

Reply via email to