Hi Jacob

Thanks for the v3.
LGTM
Acked-by: Xavier Simonart <[email protected]>

Thanks
Xavier


On Mon, Oct 26, 2026 at 2:14 AM Jacob Tanenbaum <[email protected]> wrote:

> 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