The IGMP/MLD querier injects a single query through the
multicast flood group.  The flood group's OpenFlow flow
contains one "load OUTPORT; resubmit(CHECK_LOOPBACK)"
action per local port, and OVS translates the entire thing
in a single packet_xlate().  The translation's resubmit
counter accumulates across all of those ports, so on a node
with many local ports (~330) the translation exceeds OVS's
MAX_RESUBMITS (4096) budget, packet_xlate() returns
OFPFMFC_UNKNOWN, and the query is dropped.

Rather than relying on the flood group, have the querier
inject the query directly to the flood group's local ports.
consider_mc_group() already determines exactly which local
ports the flood group would deliver to (and each port's
conntrack zone); publish that list to pinctrl so the querier
can fan the query out per port.  Publish an empty list when
the flood group has no local ports on this chassis (including
datapaths with no local ports at all), so the querier can
distinguish that from a list that has not been published
yet.

The querier sends one OpenFlow PACKET_OUT per batch of up to
100 ports.  OVS executes every resubmit in an action list
(the flood group relies on this), and a batch of 100 ports
uses ~1900 of the 4096-resubmit budget because the per-port
egress path is a fixed ~19 resubmits.  The single flood
query of ~330 ports used ~6300 resubmits.

Each port's query still traverses the per-port egress
pipeline, so multicast ACLs still filter which ports
receive it.  If the port list has not been published
yet (first transient cycles), the querier skips the query
and retries every second; once published, an empty list
means the flood group has no local ports, so the query is
skipped on the regular query interval.  A flood-group query
would not be delivered locally in either state.

Tested with the IGMP/MLD snoop/querier/relay, external
querier, and multicast relay unit tests (real
ovn-controller + vswitchd, pcap-validated query delivery).

Reported-at: https://redhat.atlassian.net/browse/FDP-4445
Assisted-by: Qwen3.8-27B-FP8, opencode
Signed-off-by: Jacob Tanenbaum <[email protected]>

---

v3
    - changed the check on the delete to
      if (!get_local_datapath(local_datapaths, port_list->dp_key)
    - changed ip_mcast_querier_send_query() to send IPv6 mld regardless
      of it IPv4 igmp succeeds or not.
    - in the test "IGMP/MLD querier per-port query fan-out first query
      at controller startup" I removed the racy OVS_WAIT_UNIT that
      required n_packets=2 in favor of using pcap to count the number of
      packets and make sure it increments by two.

v2
    - changed the Reported-at: tag to be the upstream card
    - Removal of querier port lists for datapaths that no longer exist
    - changed several calls to HMAP_FOR_EACH -> HMAP_FOR_EACH_WITH_HASH
    - consolidated ip_mcast_querier_ports_published() and
      ip_mcast_qurier_get_ports() into ip_mcast_querier_fetch_ports()
    - trimmed comments
      the boundries of sending messages and that it can send out queries
      to more ports than the original code based
    - trimmed comments in controller/pinctrl.c
    - WARN and drop the packet when queries are too big
    - added test "IGMP/MLD querier per-port query fan-out" that tests
    - added test "IGMP/MLD querier per-port query fan-out interval
      cadence" that checks the query rounds arrive when they should in
      the published state
    - added test "IGMP/MLD querier per-port query fan-out first query
      at controller startup" that shows the initial query arrives
      immediatly and not at the end of an interval
    - added test "IGMP/MLD querier per-port query fan-out empty flood
      group" tests the published but empty list.
---
 controller/physical.c |  44 +++
 controller/pinctrl.c  | 289 +++++++++++++++----
 controller/pinctrl.h  |  17 ++
 tests/ovn.at          | 643 ++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 936 insertions(+), 57 deletions(-)

diff --git a/controller/physical.c b/controller/physical.c
index 8c5d9b940..554e611d6 100644
--- a/controller/physical.c
+++ b/controller/physical.c
@@ -2922,6 +2922,22 @@ mc_ofctrl_add_flow(const struct sbrec_multicast_group 
*mc,
     ofpbuf_clear(&ctx->ofpacts);
 }
 
+/* Appends (port_key, ct_zone) to the array of flood_ports. */
+static void
+flood_querier_port_add(struct mcast_querier_port *flood_ports,
+                       size_t *n_flood_ports, uint32_t port_key,
+                       int zone_id)
+{
+    if (!flood_ports) {
+        return;
+    }
+    size_t i = *n_flood_ports;
+
+    flood_ports[i].port_key = port_key;
+    flood_ports[i].ct_zone = zone_id;
+    (*n_flood_ports)++;
+}
+
 static void
 consider_mc_group(const struct physical_ctx *ctx,
                   const struct sbrec_multicast_group *mc,
@@ -2930,12 +2946,24 @@ consider_mc_group(const struct physical_ctx *ctx,
     struct local_datapath *ldp = get_local_datapath(ctx->local_datapaths,
                                                     mc->datapath->tunnel_key);
     if (!ldp) {
+        /* No local ports: publish an empty list so the querier can
+         * distinguish this from a list that has not been published yet. */
+        if (mc->tunnel_key == OVN_MCAST_FLOOD_TUNNEL_KEY) {
+            pinctrl_mcast_querier_set_ports(mc->datapath->tunnel_key, NULL,
+                                            0);
+        }
         return;
     }
 
     struct sset remote_chassis = SSET_INITIALIZER(&remote_chassis);
     struct sset vtep_chassis = SSET_INITIALIZER(&vtep_chassis);
 
+    struct mcast_querier_port *flood_ports = NULL;
+    size_t n_flood_ports = 0;
+    if (mc->tunnel_key == OVN_MCAST_FLOOD_TUNNEL_KEY) {
+        flood_ports = xzalloc(mc->n_ports * sizeof *flood_ports);
+    }
+
     /* Go through all of the ports in the multicast group:
      *
      *    - For remote ports, add the chassis to 'remote_chassis' or
@@ -3005,6 +3033,8 @@ consider_mc_group(const struct physical_ctx *ctx,
             if (ldp->is_transit_switch) {
                 local_set_ct_zone_and_output_pb(port->tunnel_key, zone_id,
                                                 &local_ctx->ofpacts);
+                flood_querier_port_add(flood_ports, &n_flood_ports,
+                                       port->tunnel_key, zone_id);
             } else {
                 local_output_pb(port->tunnel_key, &remote_ctx->ofpacts);
                 local_output_pb(port->tunnel_key, &ramp_ctx->ofpacts);
@@ -3026,9 +3056,13 @@ consider_mc_group(const struct physical_ctx *ctx,
                        || type == LP_L3GATEWAY)) {
             local_set_ct_zone_and_output_pb(port->tunnel_key, zone_id,
                                             &local_ctx->ofpacts);
+            flood_querier_port_add(flood_ports, &n_flood_ports,
+                                   port->tunnel_key, zone_id);
         } else if (simap_contains(ctx->patch_ofports, port->logical_port)) {
             local_set_ct_zone_and_output_pb(port->tunnel_key, zone_id,
                                             &local_ctx->ofpacts);
+            flood_querier_port_add(flood_ports, &n_flood_ports,
+                                   port->tunnel_key, zone_id);
         } else if (type == LP_CHASSISREDIRECT
                    && port->chassis == ctx->chassis) {
             const char *distributed_port = smap_get(&port->options,
@@ -3042,6 +3076,9 @@ consider_mc_group(const struct physical_ctx *ctx,
                     local_set_ct_zone_and_output_pb(
                         distributed_binding->tunnel_key, zone_id,
                         &local_ctx->ofpacts);
+                    flood_querier_port_add(flood_ports, &n_flood_ports,
+                                           distributed_binding->tunnel_key,
+                                           zone_id);
                 }
             }
         } else if (!get_localnet_port(ctx->local_datapaths,
@@ -3068,6 +3105,13 @@ consider_mc_group(const struct physical_ctx *ctx,
         }
     }
 
+    /* Publish the local ports of the flood group to the querier. */
+    if (flood_ports) {
+        pinctrl_mcast_querier_set_ports(mc->datapath->tunnel_key, flood_ports,
+                                        n_flood_ports);
+        free(flood_ports);
+    }
+
     bool local_lports = local_ctx->ofpacts.size > 0;
     bool remote_ports = remote_ctx->ofpacts.size > 0;
     bool ramp_ports = ramp_ctx->ofpacts.size > 0;
diff --git a/controller/pinctrl.c b/controller/pinctrl.c
index 5c9634b24..f922d64b1 100644
--- a/controller/pinctrl.c
+++ b/controller/pinctrl.c
@@ -5436,6 +5436,23 @@ static struct hmap mcast_snoop_map 
OVS_GUARDED_BY(pinctrl_mutex);
  */
 static struct ovs_list mcast_query_list;
 
+/*
+ * Local ports of each datapath's multicast flood group, published by
+ * physical_run() (main thread) for the querier (pinctrl_handler thread).
+ *
+ * An entry exists once the datapath's flood group has been considered at
+ * least once and may be empty when the flood group has no local ports on
+ * this chassis.  Both threads access the hmap under pinctrl_mutex.
+ */
+struct mcast_querier_port_list {
+    struct hmap_node hmap_node;
+    uint32_t dp_key;
+    size_t n_ports;
+    struct mcast_querier_port *ports;
+};
+
+static struct hmap mcast_querier_ports OVS_GUARDED_BY(pinctrl_mutex);
+
 /* Multicast config information stored independently by datapath key.
  * Protected by pinctrl_mutex. pinctrl_handler has RO access and pinctrl_main
  * has RW access. Read accesses from pinctrl_ip_mcast_handle() can be
@@ -5754,6 +5771,7 @@ ip_mcast_snoop_init(void)
     hmap_init(&mcast_snoop_map);
     ovs_list_init(&mcast_query_list);
     hmap_init(&mcast_cfg_map);
+    hmap_init(&mcast_querier_ports);
 }
 
 static void
@@ -5772,6 +5790,14 @@ ip_mcast_snoop_destroy(void)
     HMAP_FOR_EACH_POP (ip_ms_state, hmap_node, &mcast_cfg_map) {
         free(ip_ms_state);
     }
+
+    struct mcast_querier_port_list *port_list;
+
+    HMAP_FOR_EACH_POP (port_list, hmap_node, &mcast_querier_ports) {
+        free(port_list->ports);
+        free(port_list);
+    }
+    hmap_destroy(&mcast_querier_ports);
 }
 
 static void
@@ -5895,6 +5921,16 @@ ip_mcast_sync(struct ovsdb_idl_txn *ovnsb_idl_txn,
         }
     }
 
+    /* Remove the querier port lists of datapaths that no longer exist */
+    struct mcast_querier_port_list *port_list;
+    HMAP_FOR_EACH_SAFE (port_list, hmap_node, &mcast_querier_ports) {
+        if (!get_local_datapath(local_datapaths, port_list->dp_key)) {
+            hmap_remove(&mcast_querier_ports, &port_list->hmap_node);
+            free(port_list->ports);
+            free(port_list);
+        }
+    }
+
     const struct sbrec_igmp_group *sbrec_ip_mrouter;
     const struct sbrec_igmp_group *sbrec_igmp;
 
@@ -6204,10 +6240,12 @@ pinctrl_ip_mcast_handle(struct rconn *swconn,
     }
 }
 
-static void
-ip_mcast_querier_send_igmp(struct rconn *swconn, struct ip_mcast_snoop *ip_ms)
+/* Composes an IGMPv3 multicast query into 'buf' (up to 'buf_size' bytes) and
+ * returns its length in bytes. */
+static size_t
+ip_mcast_querier_compose_igmp(struct ip_mcast_snoop *ip_ms, void *buf,
+                              size_t buf_size)
 {
-    /* Compose a multicast query. */
     uint64_t packet_stub[128 / 8];
     struct dp_packet packet;
 
@@ -6223,35 +6261,25 @@ ip_mcast_querier_send_igmp(struct rconn *swconn, struct 
ip_mcast_snoop *ip_ms)
     uint8_t qqic = max_response;
     packet_set_igmp3_query(&packet, max_response, 0, false, 0, qqic);
 
-    /* Inject multicast query. */
-    uint64_t ofpacts_stub[4096 / 8];
-    struct ofpbuf ofpacts = OFPBUF_STUB_INITIALIZER(ofpacts_stub);
-    enum ofp_version version = rconn_get_version(swconn);
-    put_load(ip_ms->dp_key, MFF_LOG_DATAPATH, 0, 64, &ofpacts);
-    put_load(OVN_MCAST_FLOOD_TUNNEL_KEY, MFF_LOG_OUTPORT, 0, 32, &ofpacts);
-    put_load(1, MFF_LOG_FLAGS, MLF_LOCAL_ONLY, 1, &ofpacts);
-    struct ofpact_resubmit *resubmit = ofpact_put_RESUBMIT(&ofpacts);
-    resubmit->in_port = OFPP_CONTROLLER;
-    resubmit->table_id = OFTABLE_LOCAL_OUTPUT;
-
-    struct ofputil_packet_out po = {
-        .packet = dp_packet_data(&packet),
-        .packet_len = dp_packet_size(&packet),
-        .buffer_id = UINT32_MAX,
-        .ofpacts = ofpacts.data,
-        .ofpacts_len = ofpacts.size,
-    };
-    match_set_in_port(&po.flow_metadata, OFPP_CONTROLLER);
-    enum ofputil_protocol proto = ofputil_protocol_from_ofp_version(version);
-    queue_msg(swconn, ofputil_encode_packet_out(&po, proto));
+    size_t size = dp_packet_size(&packet);
+    if (size > buf_size) {
+        static struct vlog_rate_limit rl = VLOG_RATE_LIMIT_INIT(1, 5);
+        VLOG_WARN_RL(&rl, "IGMP query (%"PRIuSIZE" bytes) too large for "
+                     "%"PRIuSIZE"-byte buffer", size, buf_size);
+        dp_packet_uninit(&packet);
+        return 0;
+    }
+    memcpy(buf, dp_packet_data(&packet), size);
     dp_packet_uninit(&packet);
-    ofpbuf_uninit(&ofpacts);
+    return size;
 }
 
-static void
-ip_mcast_querier_send_mld(struct rconn *swconn, struct ip_mcast_snoop *ip_ms)
+/* Composes an MLDv2 multicast query into 'buf' (up to 'buf_size' bytes) and
+ * returns its length in bytes. */
+static size_t
+ip_mcast_querier_compose_mld(struct ip_mcast_snoop *ip_ms, void *buf,
+                             size_t buf_size)
 {
-    /* Compose a multicast query. */
     uint64_t packet_stub[128 / 8];
     struct dp_packet packet;
 
@@ -6273,31 +6301,132 @@ ip_mcast_querier_send_mld(struct rconn *swconn, struct 
ip_mcast_snoop *ip_ms)
     struct in6_addr unspecified = { { { 0 } } };
     packet_set_mld_query(&packet, max_response, &unspecified, false, 0, qqic);
 
-    /* Inject multicast query. */
-    uint64_t ofpacts_stub[4096 / 8];
-    struct ofpbuf ofpacts = OFPBUF_STUB_INITIALIZER(ofpacts_stub);
-    enum ofp_version version = rconn_get_version(swconn);
-    put_load(ip_ms->dp_key, MFF_LOG_DATAPATH, 0, 64, &ofpacts);
-    put_load(OVN_MCAST_FLOOD_TUNNEL_KEY, MFF_LOG_OUTPORT, 0, 32, &ofpacts);
-    put_load(1, MFF_LOG_FLAGS, MLF_LOCAL_ONLY, 1, &ofpacts);
-    struct ofpact_resubmit *resubmit = ofpact_put_RESUBMIT(&ofpacts);
-    resubmit->in_port = OFPP_CONTROLLER;
-    resubmit->table_id = OFTABLE_LOCAL_OUTPUT;
-
-    struct ofputil_packet_out po = {
-        .packet = dp_packet_data(&packet),
-        .packet_len = dp_packet_size(&packet),
-        .buffer_id = UINT32_MAX,
-        .ofpacts = ofpacts.data,
-        .ofpacts_len = ofpacts.size,
-    };
-    match_set_in_port(&po.flow_metadata, OFPP_CONTROLLER);
-    enum ofputil_protocol proto = ofputil_protocol_from_ofp_version(version);
-    queue_msg(swconn, ofputil_encode_packet_out(&po, proto));
+    size_t size = dp_packet_size(&packet);
+    if (size > buf_size) {
+        static struct vlog_rate_limit rl = VLOG_RATE_LIMIT_INIT(1, 5);
+        VLOG_WARN_RL(&rl, "MLD query (%"PRIuSIZE" bytes) too large for "
+                     "%"PRIuSIZE"-byte buffer", size, buf_size);
+        dp_packet_uninit(&packet);
+        return 0;
+    }
+    memcpy(buf, dp_packet_data(&packet), size);
     dp_packet_uninit(&packet);
-    ofpbuf_uninit(&ofpacts);
+    return size;
+}
+
+/* Number of local ports per querier PACKET_OUT.
+ *
+ * (MCAST_QUERIER_PORT_BATCH) * (resubmits in egress pipeline) must be
+ * less than MAX_RESUBMITS. */
+#define MCAST_QUERIER_PORT_BATCH 100
+
+/* Sends query to each port in ports in batches of at most
+ * MCAST_QUERIER_PORT_BATCH ports per PACKET_OUT. */
+static void
+ip_mcast_querier_send_port_fanout(struct rconn *swconn,
+                                  struct ip_mcast_snoop *ip_ms,
+                                  const void *query, size_t query_len,
+                                  const struct mcast_querier_port *ports,
+                                  size_t n_ports)
+{
+    enum ofputil_protocol proto = ofputil_protocol_from_ofp_version(
+        rconn_get_version(swconn));
+
+    for (size_t start = 0; start < n_ports;
+         start += MCAST_QUERIER_PORT_BATCH) {
+        size_t end = MIN(start + MCAST_QUERIER_PORT_BATCH, n_ports);
+
+        struct ofpbuf ofpacts;
+        ofpbuf_init(&ofpacts, 0);
+        put_load(ip_ms->dp_key, MFF_LOG_DATAPATH, 0, 64, &ofpacts);
+        for (size_t i = start; i < end; i++) {
+            const struct mcast_querier_port *port = &ports[i];
+
+            if (port->ct_zone) {
+                put_load(port->ct_zone, MFF_LOG_CT_ZONE, 0, 16, &ofpacts);
+            }
+            put_load(port->port_key, MFF_LOG_OUTPORT, 0, 32, &ofpacts);
+            struct ofpact_resubmit *resubmit = ofpact_put_RESUBMIT(&ofpacts);
+            resubmit->in_port = OFPP_CONTROLLER;
+            resubmit->table_id = OFTABLE_CHECK_LOOPBACK;
+        }
+
+        struct ofputil_packet_out po = {
+            .packet = query,
+            .packet_len = query_len,
+            .buffer_id = UINT32_MAX,
+            .ofpacts = ofpacts.data,
+            .ofpacts_len = ofpacts.size,
+        };
+        match_set_in_port(&po.flow_metadata, OFPP_CONTROLLER);
+        queue_msg(swconn, ofputil_encode_packet_out(&po, proto));
+        ofpbuf_uninit(&ofpacts);
+    }
+}
+
+/* Returns a copy of the local ports published for datapath dp_key or NULL if
+ * the list has not been published yet or is empty and if the list of local
+ * ports has been published.
+ *
+ * Takes pinctrl_mutex internally. */
+static struct mcast_querier_port *
+ip_mcast_querier_fetch_ports(int64_t dp_key, size_t *n_ports, bool *published)
+{
+    *published = false;
+    ovs_mutex_lock(&pinctrl_mutex);
+
+    size_t n = 0;
+    struct mcast_querier_port *ports = NULL;
+    struct mcast_querier_port_list *list;
+    HMAP_FOR_EACH_WITH_HASH (list, hmap_node, ip_mcast_snoop_hash(dp_key),
+                             &mcast_querier_ports) {
+        if (list->dp_key != dp_key) {
+            continue;
+        }
+        *published = true;
+        n = list->n_ports;
+        if (n) {
+            ports = xmalloc(n * sizeof *ports);
+            memcpy(ports, list->ports, n * sizeof *ports);
+        }
+        break;
+    }
+
+    ovs_mutex_unlock(&pinctrl_mutex);
+
+    *n_ports = n;
+    return ports;
 }
 
+/* Composes and sends the IGMPv3 and MLDv2 queries (if enabled) to the given
+ * local ports. */
+static void
+ip_mcast_querier_send_query(struct rconn *swconn, struct ip_mcast_snoop *ip_ms,
+                            const struct mcast_querier_port *ports,
+                            size_t n_ports)
+{
+    char query_buf[128];
+    if (ip_ms->cfg.querier_v4_enabled) {
+        size_t query_len = ip_mcast_querier_compose_igmp(ip_ms, query_buf,
+                                                         sizeof query_buf);
+        if (query_len) {
+            ip_mcast_querier_send_port_fanout(swconn, ip_ms, query_buf,
+                                              query_len, ports, n_ports);
+        }
+    }
+    if (ip_ms->cfg.querier_v6_enabled) {
+        size_t query_len = ip_mcast_querier_compose_mld(ip_ms, query_buf,
+                                                        sizeof query_buf);
+        if (query_len) {
+            ip_mcast_querier_send_port_fanout(swconn, ip_ms, query_buf,
+                                              query_len, ports, n_ports);
+        }
+    }
+}
+
+/* Retry interval (ms) while the flood group's local ports are unpublished. */
+#define MCAST_QUERIER_INITIAL_RETRY_MS 1000
+
 static long long int
 ip_mcast_querier_send(struct rconn *swconn, struct ip_mcast_snoop *ip_ms,
                       long long int current_time)
@@ -6306,16 +6435,26 @@ ip_mcast_querier_send(struct rconn *swconn, struct 
ip_mcast_snoop *ip_ms,
         return ip_ms->query_time_ms;
     }
 
-    if (ip_ms->cfg.querier_v4_enabled) {
-        ip_mcast_querier_send_igmp(swconn, ip_ms);
-    }
+    size_t n_ports;
+    bool published;
+    struct mcast_querier_port *ports =
+        ip_mcast_querier_fetch_ports(ip_ms->dp_key, &n_ports, &published);
 
-    if (ip_ms->cfg.querier_v6_enabled) {
-        ip_mcast_querier_send_mld(swconn, ip_ms);
+    /* Retry at a short backoff until the port list is published. */
+    long long int interval_ms =
+        published ? ip_ms->cfg.query_interval_s * 1000
+                  : MCAST_QUERIER_INITIAL_RETRY_MS;
+
+    ip_ms->query_time_ms = current_time + interval_ms;
+
+    /* Skip if the flood group's local ports are unpublished or empty. */
+    if (!ports) {
+        return ip_ms->query_time_ms;
     }
 
-    /* Set the next query time. */
-    ip_ms->query_time_ms = current_time + ip_ms->cfg.query_interval_s * 1000;
+    ip_mcast_querier_send_query(swconn, ip_ms, ports, n_ports);
+    free(ports);
+
     return ip_ms->query_time_ms;
 }
 
@@ -6349,6 +6488,42 @@ ip_mcast_querier_wait(long long int query_time)
     }
 }
 
+/* Sets the local ports of the flood group for datapath dp_key.
+ * ports may be NULL to store and empty list which lets the querier
+ * distinguish "no local ports" from "not yet published". */
+void
+pinctrl_mcast_querier_set_ports(int64_t dp_key,
+                                const struct mcast_querier_port *ports,
+                                size_t n_ports)
+{
+    ovs_mutex_lock(&pinctrl_mutex);
+
+    struct mcast_querier_port_list *list;
+    HMAP_FOR_EACH_WITH_HASH (list, hmap_node, ip_mcast_snoop_hash(dp_key),
+                             &mcast_querier_ports) {
+        if (list->dp_key == dp_key) {
+            break;
+        }
+    }
+    if (!list) {
+        list = xzalloc(sizeof *list);
+        list->dp_key = dp_key;
+        hmap_insert(&mcast_querier_ports, &list->hmap_node,
+                    ip_mcast_snoop_hash(dp_key));
+    }
+
+    free(list->ports);
+    if (n_ports) {
+        list->ports = xmalloc(n_ports * sizeof *list->ports);
+        memcpy(list->ports, ports, n_ports * sizeof *list->ports);
+    } else {
+        list->ports = NULL;
+    }
+    list->n_ports = n_ports;
+
+    ovs_mutex_unlock(&pinctrl_mutex);
+}
+
 static void
 send_garp_rarp_wait(long long int send_garp_rarp_time)
 {
diff --git a/controller/pinctrl.h b/controller/pinctrl.h
index a638fe29f..3e38ebf51 100644
--- a/controller/pinctrl.h
+++ b/controller/pinctrl.h
@@ -86,4 +86,21 @@ void send_self_originated_neigh_packet(struct rconn *swconn,
                                        struct in6_addr *local,
                                        struct in6_addr *target,
                                        uint8_t table_id);
+
+/* A local port that the multicast flood group would deliver a query to, along
+ * with the conntrack zone that the flood group's per-port action loads. */
+struct mcast_querier_port {
+    uint32_t port_key;
+    uint16_t ct_zone;
+};
+
+/* Records the local ports of the flood group for datapath 'dp_key', so the
+ * querier can send its query to each port directly rather than through the
+ * flood group.  'ports' may be NULL to record an empty list.
+ *
+ * Called from the main thread; read by the handler thread.
+ * Takes pinctrl_mutex internally. */
+void pinctrl_mcast_querier_set_ports(int64_t dp_key,
+                                     const struct mcast_querier_port *ports,
+                                     size_t n_ports);
 #endif /* controller/pinctrl.h */
diff --git a/tests/ovn.at b/tests/ovn.at
index 49674266f..780149467 100644
--- a/tests/ovn.at
+++ b/tests/ovn.at
@@ -26150,6 +26150,649 @@ OVN_CLEANUP([hv1], [hv2])
 AT_CLEANUP
 ])
 
+OVN_FOR_EACH_NORTHD([
+AT_SETUP([IGMP/MLD querier per-port query fan-out])
+AT_KEYWORDS([IP-multicast snoop querier])
+AT_KEYWORDS([slowtest])
+
+ovn_start
+
+dnl A single chassis with a large number of local VIFs on one logical
+dnl switch.  Each port's querier query egress translation uses ~19
+dnl resubmits, so delivering the queries to all 250 local ports in a
+dnl single flood-group PACKET_OUT would exceed OVS's MAX_RESUBMITS
+dnl (4096) budget.  Instead, the querier sends one PACKET_OUT per
+dnl batch of at most 100 ports.  Verify end to end that (1) every
+dnl local port receives both the IGMP and the MLD queries, with the
+dnl right contents, and (2) the number of OFPT_PACKET_OUT messages
+dnl matches the batch count: three per query type with 250 local
+dnl ports, two with 101, one with 100.
+dnl
+dnl sim_add() starts ovs-vswitchd with -vvconn, so every OpenFlow
+dnl message received from ovn-controller is dumped to
+dnl ovs-vswitchd.log.  Counting the "received: OFPT_PACKET_OUT" log
+dnl lines therefore counts the messages the querier actually sent.
+
+n_ports=250
+
+check ovn-nbctl ls-add ls1
+
+# Create the VIF ports on ls1 in batches.
+batch=""
+for i in $(seq 1 $n_ports); do
+    batch="$batch -- lsp-add ls1 ls1-p$i"
+    if test $((i % 50)) -eq 0; then
+        check ovn-nbctl $batch
+        batch=""
+    fi
+done
+if test -n "$batch"; then
+    check ovn-nbctl $batch
+fi
+
+net_add n1
+sim_add hv1
+as hv1
+check ovs-vsctl add-br br-phys
+ovn_attach n1 br-phys 192.168.0.1
+check ovs-vsctl set open . external-ids:ovn-bridge-mappings=phys:br-phys
+
+# Create the VIFs in batches, keeping tx pcaps on a few of them.
+batch=""
+for i in $(seq 1 $n_ports); do
+    if test $i -eq 1 -o $i -eq 125 -o $i -eq $n_ports; then
+        batch="$batch -- add-port br-int hv1-vif$i -- \
+            set interface hv1-vif$i external-ids:iface-id=ls1-p$i \
+            options:tx_pcap=hv1/vif$i-tx.pcap \
+            options:rxq_pcap=hv1/vif$i-rx.pcap"
+    else
+        batch="$batch -- add-port br-int hv1-vif$i -- \
+            set interface hv1-vif$i external-ids:iface-id=ls1-p$i"
+    fi
+    if test $((i % 25)) -eq 0; then
+        check ovs-vsctl $batch
+        batch=""
+    fi
+done
+if test -n "$batch"; then
+    check ovs-vsctl $batch
+fi
+
+wait_for_ports_up
+check ovn-nbctl --wait=hv sync
+
+# Enable multicast snooping and querier on ls1.  Use the maximum
+# query interval so that only the immediate query round fired when
+# the querier is enabled (and, later, when the interval changes)
+# happens during the test.
+check ovn-nbctl --wait=hv set Logical_Switch ls1 \
+    other_config:mcast_snoop="true" \
+    other_config:mcast_querier="true" \
+    other_config:mcast_query_interval=3600 \
+    other_config:mcast_eth_src="00:00:00:00:02:fe" \
+    other_config:mcast_ip4_src="20.0.0.254" \
+    other_config:mcast_ip6_src="fe80::fe"
+
+# Wait for pinctrl thread to be connected
+OVS_WAIT_UNTIL([grep pinctrl hv1/ovn-controller.log | grep -c connected])
+
+dp_key=$(printf "%x" $(fetch_column Datapath_Binding tunnel_key 
external_ids:name=ls1))
+
+AS_BOX([Querier queries are fanned out to all local ports])
+dnl Every local port has its own output flow in table 89, the last
+dnl logical egress table.  The querier sends one IGMP query and one
+dnl MLD query per port per round, so once each per-port flow has
+dnl counted both packets, every local port has received both
+dnl queries.
+OVS_WAIT_UNTIL(
+  [test $(as hv1 ovs-ofctl dump-flows br-int table=OFTABLE_LOG_TO_PHY | grep 
-c "metadata=0x$dp_key ") -eq $n_ports \
+       -a $(as hv1 ovs-ofctl dump-flows br-int table=OFTABLE_LOG_TO_PHY | grep 
"metadata=0x$dp_key " | grep -c "n_packets=2,") -eq $n_ports],
+  [as hv1 ovs-ofctl dump-flows br-int table=OFTABLE_LOG_TO_PHY])
+
+AS_BOX([Querier query contents])
+AT_CAPTURE_FILE([exp])
+AT_CAPTURE_FILE([rcv])
+check_packets() {
+    > exp
+    > rcv
+    if test "$1" = --uniq; then
+        sort="sort -u"; shift
+    else
+        sort=sort
+    fi
+    for tuple in "$@"; do
+        set $tuple; pcap=$1 type=$2
+        echo "--- $pcap" | tee -a exp >> rcv
+        $sort "$type" >> exp
+        $PYTHON "$ovs_srcdir/utilities/ovs-pcap.in" $pcap | $sort >> rcv
+        echo | tee -a exp >> rcv
+    done
+
+    $at_diff exp rcv >/dev/null
+}
+
+> expected
+store_igmp_v3_query 0000000002fe $(ip_to_hex 20 0 0 254) 84dd expected
+store_mld_query 0000000002fe fe8000000000000000000000000000fe expected
+
+OVS_WAIT_UNTIL(
+  [check_packets --uniq 'hv1/vif1-tx.pcap expected' \
+                 'hv1/vif125-tx.pcap expected' \
+                 'hv1/vif250-tx.pcap expected'],
+  [$at_diff -F'^---' exp rcv])
+
+AS_BOX([250 local ports require three messages per query type])
+dnl One round over 250 local ports: 3 batches of IGMP + 3 batches
+dnl of MLD, i.e. 6 OFPT_PACKET_OUT messages in total.  The round is
+dnl known to be fully delivered at this point (see above), so the
+dnl count is exact.
+OVS_WAIT_UNTIL(
+  [test $(grep -c "received: OFPT_PACKET_OUT" hv1/ovs-vswitchd.log) -eq 6],
+  [grep "received: OFPT_PACKET_OUT" hv1/ovs-vswitchd.log])
+
+AS_BOX([101 local ports require two messages per query type])
+dnl Drop the ports above 101.  Changing the query interval triggers
+dnl an immediate next query round (see ip_mcast_snoop_configure() in
+dnl ovn-controller), now over the 101 remaining ports, i.e. two
+dnl messages per query type, four in total.
+batch=""
+for i in $(seq 102 $n_ports); do
+    batch="$batch -- lsp-del ls1-p$i"
+    if test $((i % 50)) -eq 0; then
+        check ovn-nbctl $batch
+        batch=""
+    fi
+done
+if test -n "$batch"; then
+    check ovn-nbctl $batch
+fi
+
+batch=""
+for i in $(seq 102 $n_ports); do
+    batch="$batch -- del-port br-int hv1-vif$i"
+    if test $((i % 25)) -eq 0; then
+        check ovs-vsctl $batch
+        batch=""
+    fi
+done
+if test -n "$batch"; then
+    check ovs-vsctl $batch
+fi
+
+check ovn-nbctl --wait=hv sync
+check ovn-nbctl --wait=hv set Logical_Switch ls1 
other_config:mcast_query_interval=1800
+
+OVS_WAIT_UNTIL(
+  [test $(as hv1 ovs-ofctl dump-flows br-int table=OFTABLE_LOG_TO_PHY | grep 
-c "metadata=0x$dp_key ") -eq 101 \
+       -a $(as hv1 ovs-ofctl dump-flows br-int table=OFTABLE_LOG_TO_PHY | grep 
"metadata=0x$dp_key " | grep -c "n_packets=4,") -eq 101],
+  [as hv1 ovs-ofctl dump-flows br-int table=OFTABLE_LOG_TO_PHY])
+
+dnl Two rounds in total: 6 messages from the 250-port round plus 2
+dnl batches of IGMP + 2 batches of MLD over the 101 remaining ports.
+OVS_WAIT_UNTIL(
+  [test $(grep -c "received: OFPT_PACKET_OUT" hv1/ovs-vswitchd.log) -eq 10],
+  [grep "received: OFPT_PACKET_OUT" hv1/ovs-vswitchd.log])
+
+AS_BOX([100 local ports require one message per query type])
+dnl Drop one more local port.  Changing the query interval again
+dnl triggers another immediate query round, now over the 100
+dnl remaining ports, i.e. one message per query type, two in total.
+check ovn-nbctl lsp-del ls1-p101
+check ovs-vsctl --if-exists del-port br-int hv1-vif101
+check ovn-nbctl --wait=hv sync
+check ovn-nbctl --wait=hv set Logical_Switch ls1 
other_config:mcast_query_interval=900
+
+OVS_WAIT_UNTIL(
+  [test $(as hv1 ovs-ofctl dump-flows br-int table=OFTABLE_LOG_TO_PHY | grep 
-c "metadata=0x$dp_key ") -eq 100 \
+       -a $(as hv1 ovs-ofctl dump-flows br-int table=OFTABLE_LOG_TO_PHY | grep 
"metadata=0x$dp_key " | grep -c "n_packets=6,") -eq 100],
+  [as hv1 ovs-ofctl dump-flows br-int table=OFTABLE_LOG_TO_PHY])
+
+dnl Three rounds in total: the 6 + 4 messages above plus 1 batch of
+dnl IGMP + 1 batch of MLD over the 100 remaining ports.
+OVS_WAIT_UNTIL(
+  [test $(grep -c "received: OFPT_PACKET_OUT" hv1/ovs-vswitchd.log) -eq 12],
+  [grep "received: OFPT_PACKET_OUT" hv1/ovs-vswitchd.log])
+
+OVN_CLEANUP([hv1])
+AT_CLEANUP
+])
+
+OVN_FOR_EACH_NORTHD([
+AT_SETUP([IGMP/MLD querier per-port query fan-out interval cadence])
+AT_KEYWORDS([IP-multicast snoop querier])
+AT_KEYWORDS([slowtest])
+
+ovn_start
+
+dnl A single chassis with two local VIFs on one logical switch.
+dnl Each querier round sends one IGMP query and one MLD query (a
+dnl single batch for both ports), so every round shows up as two
+dnl "received: OFPT_PACKET_OUT" lines in ovs-vswitchd.log, which
+dnl sim_add() starts with -vvconn.
+dnl
+dnl Verify that (1) consecutive rounds are spaced by the configured
+dnl 3s query interval, (2) extending the interval does not reset the
+dnl pending round, and (3) shortening the interval triggers an
+dnl immediate round.
+dnl
+dnl Known limitation: the interval extension is applied by the
+dnl controller asynchronously.  The assertions assume it is applied
+dnl within a couple of seconds; on a heavily loaded system an extra
+dnl round could fire and the exact count checks would fail.
+
+check ovn-nbctl ls-add ls1
+check ovn-nbctl lsp-add ls1 ls1-p1
+check ovn-nbctl lsp-add ls1 ls1-p2
+
+net_add n1
+sim_add hv1
+as hv1
+check ovs-vsctl add-br br-phys
+ovn_attach n1 br-phys 192.168.0.1
+check ovs-vsctl set open . external-ids:ovn-bridge-mappings=phys:br-phys
+check ovs-vsctl add-port br-int hv1-vif1 -- \
+    set interface hv1-vif1 external-ids:iface-id=ls1-p1 \
+    options:tx_pcap=hv1/vif1-tx.pcap
+check ovs-vsctl add-port br-int hv1-vif2 -- \
+    set interface hv1-vif2 external-ids:iface-id=ls1-p2 \
+    options:tx_pcap=hv1/vif2-tx.pcap
+
+wait_for_ports_up
+check ovn-nbctl --wait=hv sync
+
+# Enable multicast snooping and querier on ls1 with a short query
+# interval so that several query rounds fire during the test.
+check ovn-nbctl --wait=hv set Logical_Switch ls1 \
+    other_config:mcast_snoop="true" \
+    other_config:mcast_querier="true" \
+    other_config:mcast_query_interval=3 \
+    other_config:mcast_eth_src="00:00:00:00:02:fe" \
+    other_config:mcast_ip4_src="20.0.0.254" \
+    other_config:mcast_ip6_src="fe80::fe"
+
+# Wait for pinctrl thread to be connected
+OVS_WAIT_UNTIL([grep pinctrl hv1/ovn-controller.log | grep -c connected])
+
+dp_key=$(printf "%x" $(fetch_column Datapath_Binding tunnel_key 
external_ids:name=ls1))
+
+packet_out_count() {
+    grep -c "received: OFPT_PACKET_OUT" hv1/ovs-vswitchd.log
+}
+packet_out_ts() {
+    dnl Prints the send time (in ms) of the $1-th OFPT_PACKET_OUT
+    dnl message in hv1/ovs-vswitchd.log.  The first field of a log
+    dnl line is the UTC timestamp with millisecond precision.
+    grep "received: OFPT_PACKET_OUT" hv1/ovs-vswitchd.log \
+        | awk -F'|' -v n="$1" 'NR==n { print $1 }' \
+        | while read -r ts; do date -d "$ts" +%s%3N; done
+}
+round_time() {
+    dnl The IGMP query of round $1 is the (2n-1)-th OFPT_PACKET_OUT.
+    packet_out_ts $((2 * $1 - 1))
+}
+check_interval_gap() {
+    gap=$(( $2 - $1 ))
+    check test "$gap" -ge $3 -a "$gap" -le $4
+}
+
+AS_BOX([Query rounds follow the configured interval])
+OVS_WAIT_UNTIL(
+  [test $(packet_out_count) -ge 2],
+  [grep "received: OFPT_PACKET_OUT" hv1/ovs-vswitchd.log])
+r1=$(round_time 1)
+OVS_WAIT_UNTIL(
+  [test $(packet_out_count) -ge 8],
+  [grep "received: OFPT_PACKET_OUT" hv1/ovs-vswitchd.log])
+r2=$(round_time 2)
+r3=$(round_time 3)
+r4=$(round_time 4)
+
+check_interval_gap $r1 $r2 2600 3600
+check_interval_gap $r2 $r3 2600 3600
+check_interval_gap $r3 $r4 2600 3600
+total=$(( r4 - r1 ))
+check test "$total" -ge 8000 -a "$total" -le 12000
+
+AS_BOX([Extending the interval keeps the pending round])
+dnl The pending fifth round was scheduled with the old 3s interval,
+dnl so it must still fire on time even after the interval is
+dnl extended.
+check ovn-nbctl --wait=hv set Logical_Switch ls1 
other_config:mcast_query_interval=3600
+
+OVS_WAIT_UNTIL(
+  [test $(packet_out_count) -ge 10],
+  [grep "received: OFPT_PACKET_OUT" hv1/ovs-vswitchd.log])
+r5=$(round_time 5)
+check_interval_gap $r4 $r5 2600 4500
+
+dnl If the controller had not applied the extended interval before
+dnl the fifth round was rescheduled, a sixth round would have fired
+dnl right after it.
+sleep 4
+check test $(packet_out_count) -eq 10
+
+AS_BOX([Shortening the interval triggers an immediate round])
+check ovn-nbctl --wait=hv set Logical_Switch ls1 
other_config:mcast_query_interval=1800
+
+OVS_WAIT_UNTIL(
+  [test $(packet_out_count) -eq 12],
+  [grep "received: OFPT_PACKET_OUT" hv1/ovs-vswitchd.log])
+r6=$(round_time 6)
+dnl The immediate round fires well before the new 1800s interval.
+check test $(( r6 - r5 )) -le 10000
+
+OVS_WAIT_UNTIL(
+  [test $(as hv1 ovs-ofctl dump-flows br-int table=OFTABLE_LOG_TO_PHY | grep 
-c "metadata=0x$dp_key ") -eq 2 \
+       -a $(as hv1 ovs-ofctl dump-flows br-int table=OFTABLE_LOG_TO_PHY | grep 
"metadata=0x$dp_key " | grep -c "n_packets=12,") -eq 2],
+  [as hv1 ovs-ofctl dump-flows br-int table=OFTABLE_LOG_TO_PHY])
+
+AS_BOX([Querier query contents])
+AT_CAPTURE_FILE([exp])
+AT_CAPTURE_FILE([rcv])
+check_packets() {
+    > exp
+    > rcv
+    if test "$1" = --uniq; then
+        sort="sort -u"; shift
+    else
+        sort=sort
+    fi
+    for tuple in "$@"; do
+        set $tuple; pcap=$1 type=$2
+        echo "--- $pcap" | tee -a exp >> rcv
+        $sort "$type" >> exp
+        $PYTHON "$ovs_srcdir/utilities/ovs-pcap.in" $pcap | $sort >> rcv
+        echo | tee -a exp >> rcv
+    done
+
+    $at_diff exp rcv >/dev/null
+}
+
+> expected
+store_igmp_v3_query 0000000002fe $(ip_to_hex 20 0 0 254) 84dd expected
+store_mld_query 0000000002fe fe8000000000000000000000000000fe expected
+
+OVS_WAIT_UNTIL(
+  [check_packets --uniq 'hv1/vif1-tx.pcap expected' \
+                 'hv1/vif2-tx.pcap expected'],
+  [$at_diff -F'^---' exp rcv])
+
+OVN_CLEANUP([hv1])
+AT_CLEANUP
+])
+
+OVN_FOR_EACH_NORTHD([
+AT_SETUP([IGMP/MLD querier per-port query fan-out first query at controller 
startup])
+AT_KEYWORDS([IP-multicast snoop querier])
+AT_KEYWORDS([slowtest])
+
+ovn_start
+
+dnl Multicast snooping and the querier are configured on ls1 before
+dnl the controller starts.  When the VIF on ls1 is bound, the
+dnl querier state for the switch is created and must fire an
+dnl immediate first query round, without waiting for the (maximum)
+dnl query interval to elapse.
+dnl
+dnl Known limitations: (1) the immediate round is observed through
+dnl the OFPT_PACKET_OUT messages it generates; (2) the first round
+dnl may fire before the VIF's egress OpenFlow pipeline is installed,
+dnl in which case its packets are dropped on the way to the port.
+dnl The arrival and content checks therefore run against a second
+dnl round, triggered immediately by shortening the query interval,
+dnl whose packets are certain to reach the port.
+
+check ovn-nbctl ls-add ls1
+check ovn-nbctl lsp-add ls1 ls1-p1
+
+# Enable multicast snooping and querier on ls1 before the controller
+# starts.  Use the maximum query interval so that, if any query round
+# fires during the test, it must be the immediate first one.
+check ovn-nbctl set Logical_Switch ls1 \
+    other_config:mcast_snoop="true" \
+    other_config:mcast_querier="true" \
+    other_config:mcast_query_interval=3600 \
+    other_config:mcast_eth_src="00:00:00:00:02:fe" \
+    other_config:mcast_ip4_src="20.0.0.254" \
+    other_config:mcast_ip6_src="fe80::fe"
+
+dnl Wait until northd has processed the snoop configuration.
+OVS_WAIT_UNTIL([test -n "$(fetch_column Datapath_Binding _uuid 
external_ids:name=ls1)"])
+dp_uuid=$(fetch_column Datapath_Binding _uuid external_ids:name=ls1)
+wait_column 3600 sb:IP_Multicast query_interval datapath=$dp_uuid
+
+net_add n1
+sim_add hv1
+as hv1
+check ovs-vsctl add-br br-phys
+ovn_attach n1 br-phys 192.168.0.1
+check ovs-vsctl set open . external-ids:ovn-bridge-mappings=phys:br-phys
+check ovs-vsctl add-port br-int hv1-vif1 -- \
+    set interface hv1-vif1 external-ids:iface-id=ls1-p1 \
+    options:tx_pcap=hv1/vif1-tx.pcap
+
+wait_for_ports_up
+
+# Wait for pinctrl thread to be connected
+OVS_WAIT_UNTIL([grep pinctrl hv1/ovn-controller.log | grep -c connected])
+
+packet_out_count() {
+    grep -c "received: OFPT_PACKET_OUT" hv1/ovs-vswitchd.log
+}
+
+pcap_count() {
+    $PYTHON "$ovs_srcdir/utilities/ovs-pcap.in" hv1/vif1-tx.pcap | wc -l
+}
+
+AS_BOX([The first query round fires immediately])
+dnl One IGMP query and one MLD query are sent without waiting for
+dnl the (maximum) query interval to elapse.
+OVS_WAIT_UNTIL(
+  [test $(packet_out_count) -eq 2],
+  [grep "received: OFPT_PACKET_OUT" hv1/ovs-vswitchd.log])
+
+dnl The first round's packets may not have reached the port yet (or
+dnl may be dropped entirely if the egress pipeline is not installed);
+dnl record the captured count either way.
+first_round_count=$(pcap_count)
+
+dnl Let the VIF's egress pipeline settle, then trigger an immediate
+dnl second round by shortening the interval.  Its packets must
+dnl reach the port.
+sleep 2
+check ovn-nbctl --wait=hv set Logical_Switch ls1 
other_config:mcast_query_interval=1800
+
+OVS_WAIT_UNTIL(
+  [test $(packet_out_count) -eq 4],
+  [grep "received: OFPT_PACKET_OUT" hv1/ovs-vswitchd.log])
+
+dnl The second round must add its two queries to the capture, so the
+dnl two sets of packets arrive separately.
+OVS_WAIT_UNTIL(
+  [test $(pcap_count) -ge $((first_round_count + 2))],
+  [echo "pcap has $(pcap_count) packets, expected at least 
$((first_round_count + 2))"])
+
+AS_BOX([Querier query contents])
+AT_CAPTURE_FILE([exp])
+AT_CAPTURE_FILE([rcv])
+check_packets() {
+    > exp
+    > rcv
+    if test "$1" = --uniq; then
+        sort="sort -u"; shift
+    else
+        sort=sort
+    fi
+    for tuple in "$@"; do
+        set $tuple; pcap=$1 type=$2
+        echo "--- $pcap" | tee -a exp >> rcv
+        $sort "$type" >> exp
+        $PYTHON "$ovs_srcdir/utilities/ovs-pcap.in" $pcap | $sort >> rcv
+        echo | tee -a exp >> rcv
+    done
+
+    $at_diff exp rcv >/dev/null
+}
+
+> expected
+store_igmp_v3_query 0000000002fe $(ip_to_hex 20 0 0 254) 84dd expected
+store_mld_query 0000000002fe fe8000000000000000000000000000fe expected
+
+OVS_WAIT_UNTIL(
+  [check_packets --uniq 'hv1/vif1-tx.pcap expected'],
+  [$at_diff -F'^---' exp rcv])
+
+dnl The next round is a full query interval away.
+sleep 10
+check test $(packet_out_count) -eq 4
+
+OVN_CLEANUP([hv1])
+AT_CLEANUP
+])
+
+OVN_FOR_EACH_NORTHD([
+AT_SETUP([IGMP/MLD querier per-port query fan-out empty flood group])
+AT_KEYWORDS([IP-multicast snoop querier])
+AT_KEYWORDS([slowtest])
+
+ovn_start
+
+dnl A switch connected to a router through a static router port.
+dnl The router port belongs to the _MC_flood group, but its queries
+dnl always leave through the remote output and never to a local
+dnl port, so the switch's local flood port list stays empty.  A VIF
+dnl with receive_multicast disabled is claimed by the chassis (which
+dnl makes the switch a local datapath) without joining the
+dnl _MC_flood group.
+dnl
+dnl With an empty (but published) flood port list the querier must
+dnl not send queries and must reschedule the next round at the full
+dnl query interval, not the 1s unpublished retry.  Adding a port to
+dnl the flood group must not trigger an immediate round; shortening
+dnl the interval does.
+dnl
+dnl Known limitation: the "no queries sent" phases rely on the flood
+dnl port list being published before the querier state is created,
+dnl which holds in the current implementation because the port list
+dnl is published within the same controller loop iteration in which
+dnl the switch becomes local.
+
+check ovn-nbctl lr-add lr1
+check ovn-nbctl lrp-add lr1 lrp1 f0:00:00:00:00:f1 192.168.1.1/24
+check ovn-nbctl ls-add ls1
+check ovn-nbctl lsp-add ls1 ls1-lrp1
+check ovn-nbctl set Logical_Switch_Port ls1-lrp1 \
+    type=router \
+    options:router-port=lrp1 \
+    addresses=router
+
+# The VIF makes ls1 a local datapath on hv1 without joining the
+# _MC_flood group.
+check ovn-nbctl lsp-add ls1 ls1-p1
+check ovn-nbctl set Logical_Switch_Port ls1-p1 options:receive_multicast=false
+
+net_add n1
+sim_add hv1
+as hv1
+check ovs-vsctl add-br br-phys
+ovn_attach n1 br-phys 192.168.0.1
+check ovs-vsctl set open . external-ids:ovn-bridge-mappings=phys:br-phys
+check ovs-vsctl add-port br-int hv1-vif1 -- \
+    set interface hv1-vif1 external-ids:iface-id=ls1-p1
+
+wait_for_ports_up
+
+# Enable multicast snooping and querier on ls1 with the maximum
+# query interval.
+check ovn-nbctl --wait=hv set Logical_Switch ls1 \
+    other_config:mcast_snoop="true" \
+    other_config:mcast_querier="true" \
+    other_config:mcast_query_interval=3600 \
+    other_config:mcast_eth_src="00:00:00:00:02:fe" \
+    other_config:mcast_ip4_src="20.0.0.254" \
+    other_config:mcast_ip6_src="fe80::fe"
+
+# Wait for pinctrl thread to be connected
+OVS_WAIT_UNTIL([grep pinctrl hv1/ovn-controller.log | grep -c connected])
+
+dp_key=$(printf "%x" $(fetch_column Datapath_Binding tunnel_key 
external_ids:name=ls1))
+
+packet_out_count() {
+    grep -c "received: OFPT_PACKET_OUT" hv1/ovs-vswitchd.log
+}
+
+AS_BOX([No queries are sent while the flood group has no local ports])
+dnl The first query round finds an empty (published) local flood
+dnl port list and reschedules at the full interval; well before the
+dnl next due time no queries may have been sent.
+sleep 8
+check test $(packet_out_count) -eq 0
+
+AS_BOX([Adding a port to the flood group does not trigger a round])
+dnl Add a regular VIF.  It joins the _MC_flood group, so the flood
+dnl port list is no longer empty, but the pending round is still a
+dnl full interval away.
+check ovn-nbctl lsp-add ls1 ls1-p2
+check ovs-vsctl add-port br-int hv1-vif2 -- \
+    set interface hv1-vif2 external-ids:iface-id=ls1-p2 \
+    options:tx_pcap=hv1/vif2-tx.pcap
+wait_for_ports_up
+
+sleep 8
+check test $(packet_out_count) -eq 0
+
+AS_BOX([Shortening the interval triggers an immediate round])
+check ovn-nbctl --wait=hv set Logical_Switch ls1 
other_config:mcast_query_interval=1800
+
+OVS_WAIT_UNTIL(
+  [test $(packet_out_count) -eq 2],
+  [grep "received: OFPT_PACKET_OUT" hv1/ovs-vswitchd.log])
+
+dnl Both queries went to the new port only: the VIF flow is the only
+dnl flow of the datapath that counts packets (the router port's
+dnl flows do not receive the queries).
+OVS_WAIT_UNTIL(
+  [test $(as hv1 ovs-ofctl dump-flows br-int table=OFTABLE_LOG_TO_PHY | grep 
"metadata=0x$dp_key " | grep -c "n_packets=2,") -eq 1],
+  [as hv1 ovs-ofctl dump-flows br-int table=OFTABLE_LOG_TO_PHY])
+
+AS_BOX([Querier query contents])
+AT_CAPTURE_FILE([exp])
+AT_CAPTURE_FILE([rcv])
+check_packets() {
+    > exp
+    > rcv
+    if test "$1" = --uniq; then
+        sort="sort -u"; shift
+    else
+        sort=sort
+    fi
+    for tuple in "$@"; do
+        set $tuple; pcap=$1 type=$2
+        echo "--- $pcap" | tee -a exp >> rcv
+        $sort "$type" >> exp
+        $PYTHON "$ovs_srcdir/utilities/ovs-pcap.in" $pcap | $sort >> rcv
+        echo | tee -a exp >> rcv
+    done
+
+    $at_diff exp rcv >/dev/null
+}
+
+> expected
+store_igmp_v3_query 0000000002fe $(ip_to_hex 20 0 0 254) 84dd expected
+store_mld_query 0000000002fe fe8000000000000000000000000000fe expected
+
+OVS_WAIT_UNTIL(
+  [check_packets --uniq 'hv1/vif2-tx.pcap expected'],
+  [$at_diff -F'^---' exp rcv])
+
+dnl The next round is a full query interval away.
+sleep 10
+check test $(packet_out_count) -eq 2
+
+OVN_CLEANUP([hv1])
+AT_CLEANUP
+])
+
 OVN_FOR_EACH_NORTHD([
 AT_SETUP([unixctl socket])
 ovn_start
-- 
2.56.0

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

Reply via email to