Forgot to mention that I tested this and it looks good from the
offloading perspective, there are a still issues but those are on our end.

Just one small nit instead of testing this just from a logical switch 
perspective,
maybe it’s worth to also add a test from the router lflow point of view.

Acked-by: Alin Serdean <[email protected]>

From: Ales Musil <[email protected]>
Date: Thursday, 15 October 2026 at 13:14
To: Dumitru Ceara <[email protected]>
Cc: [email protected] <[email protected]>; [email protected] 
<[email protected]>; Tim Rozet <[email protected]>; [email protected] 
<[email protected]>; Alin Serdean <[email protected]>
Subject: Re: [PATCH ovn] northd: Avoid ct_tuple matches for non-fragments.

External email: Use caution opening links or attachments



On Tue, Oct 13, 2026 at 12:15 PM Dumitru Ceara 
<[email protected]<mailto:[email protected]>> wrote:
Megaflows matching on these are not offloadable on specific hardware
NICs.  These were originally introduced by 20a96b9d2c3d ("northd: Allow
fragmented traffic through LB.") in order to address the fact that with
the userspace datapath fragmented IP packets miss L4 headers in later
fragments, breaking load balancing.  A follow up commit, 8e6f9a8355e2
("northd: Fix HW offload problem related to ct_tuple."), tried to
address the offload issue but missed to add a check to only send the
first packet of a session (ct_state=+new) to the CT EXTRACT side table
if and only if it is a fragmented IP packet.

That means that ct_tuple matches would always be present for
megaflows forwarding unidirectional UDP traffic, even if the IP packets
are not fragmented.  Some hardware NICs may be capable of offloading
such traffic (see the first Reported-at link) but only if their
megaflows don't match on ct_tuple fields.

A previous patch attempted to fix a similar issue by limiting switch
conntrack tuple fields extraction only to traffic destined to load
balancers that are configured with L4 ports.  That approach has some
downsides and requires an additional logical flow for each load balancer
VIP.

This patch attempts fixing the issue in a different, a bit more generic
way:  it turns out we don't need to send all ct_state=+new traffic to
the CT EXTRACT table, we should instead do that only for fragments.

Reported-at: 
https://mail.openvswitch.org/pipermail/ovs-discuss/2026-October/054041.html
Reported-at: 
https://patchwork.ozlabs.org/project/ovn/patch/[email protected]/
Reported-at: https://redhat.atlassian.net/browse/FDP-4458
Fixes: 20a96b9d2c3d ("northd: Allow fragmented traffic through LB.")
Fixes: 8e6f9a8355e2 ("northd: Fix HW offload problem related to ct_tuple.")
Assisted-by: Claude Opus 4.6, OpenCode
Signed-off-by: Dumitru Ceara <[email protected]<mailto:[email protected]>>
---
 northd/northd.c     | 34 ++++++++++++++++-
 tests/ovn-northd.at<http://ovn-northd.at/> | 62 +++++++++++++++++++++++++++---
 tests/system-ovn.at<http://system-ovn.at/> | 92 
+++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 180 insertions(+), 8 deletions(-)

diff --git a/northd/northd.c b/northd/northd.c
index 7c4cb5a62f..84e830428c 100644
--- a/northd/northd.c
+++ b/northd/northd.c
@@ -18413,10 +18413,25 @@ build_lrouter_in_ct_extract_flows(struct lflow_table 
*lflows,
     /* Ingress CT_EXTRACT table: Extract connection tracking fields for new
      * connections. */
     ovn_lflow_add(lflows, od, S_ROUTER_IN_CT_EXTRACT, 100,
-                  "ct.new<http://ct.new/> && ip",
+                  "ct.new<http://ct.new/> && ip && ip.is_frag",
                   REG_CT_PROTO " = ct_proto(); "
                   REG_CT_TP_DST " = ct_tp_dst(); next;",
                   lflow_ref);
+    ovn_lflow_add(lflows, od, S_ROUTER_IN_CT_EXTRACT, 100,
+                  "ct.new<http://ct.new/> && ip && tcp && !ip.is_frag",
+                  REG_CT_PROTO " = ip.proto; "
+                  REG_CT_TP_DST " = tcp.dst; next;",
+                  lflow_ref);
+    ovn_lflow_add(lflows, od, S_ROUTER_IN_CT_EXTRACT, 100,
+                  "ct.new<http://ct.new/> && ip && udp && !ip.is_frag",
+                  REG_CT_PROTO " = ip.proto; "
+                  REG_CT_TP_DST " = udp.dst; next;",
+                  lflow_ref);
+    ovn_lflow_add(lflows, od, S_ROUTER_IN_CT_EXTRACT, 100,
+                  "ct.new<http://ct.new/> && ip && sctp && !ip.is_frag",
+                  REG_CT_PROTO " = ip.proto; "
+                  REG_CT_TP_DST " = sctp.dst; next;",
+                  lflow_ref);
 }

 static void
@@ -19699,10 +19714,25 @@ build_ls_stateful_flows(const struct 
ls_stateful_record *ls_stateful_rec,
      * balancers. */
     if (ls_stateful_rec->has_lb_vip) {
         ovn_lflow_add(lflows, od, S_SWITCH_IN_CT_EXTRACT, 100,
-                      "ct.new<http://ct.new/> && ip",
+                      "ct.new<http://ct.new/> && ip && ip.is_frag",
                       REG_CT_PROTO " = ct_proto(); "
                       REG_CT_TP_DST " = ct_tp_dst(); next;",
                       ls_stateful_rec->lflow_ref);
+        ovn_lflow_add(lflows, od, S_SWITCH_IN_CT_EXTRACT, 100,
+                      "ct.new<http://ct.new/> && ip && tcp && !ip.is_frag",
+                      REG_CT_PROTO " = ip.proto; "
+                      REG_CT_TP_DST " = tcp.dst; next;",
+                      ls_stateful_rec->lflow_ref);
+        ovn_lflow_add(lflows, od, S_SWITCH_IN_CT_EXTRACT, 100,
+                      "ct.new<http://ct.new/> && ip && udp && !ip.is_frag",
+                      REG_CT_PROTO " = ip.proto; "
+                      REG_CT_TP_DST " = udp.dst; next;",
+                      ls_stateful_rec->lflow_ref);
+        ovn_lflow_add(lflows, od, S_SWITCH_IN_CT_EXTRACT, 100,
+                      "ct.new<http://ct.new/> && ip && sctp && !ip.is_frag",
+                      REG_CT_PROTO " = ip.proto; "
+                      REG_CT_TP_DST " = sctp.dst; next;",
+                      ls_stateful_rec->lflow_ref);
     }

     build_lb_hairpin(ls_stateful_rec, od, lflows, ls_stateful_rec->lflow_ref);
diff --git a/tests/ovn-northd.at<http://ovn-northd.at/> 
b/tests/ovn-northd.at<http://ovn-northd.at/>
index 7a0a400c4a..a5622e3fbe 100644
--- a/tests/ovn-northd.at<http://ovn-northd.at/>
+++ b/tests/ovn-northd.at<http://ovn-northd.at/>
@@ -4335,8 +4335,6 @@ flow="inport == \"lsp1\" && ${flow_eth} && ${flow_ip} && 
${flow_tcp}"
 ovn_trace --ct new --ct new ls "${flow}"
 AT_CHECK_UNQUOTED([ovn_trace --ct new --ct new --minimal ls "${flow}"], [0], 
[dnl
 ct_lb_mark {
-    reg1[[16..23]] = 6;
-    reg1[[0..15]] = 80;
     ct_lb_mark {
         reg0[[6]] = 0;
         reg0[[12]] = 0;
@@ -4350,6 +4348,20 @@ ct_lb_mark {
 # UDP packets should go to conntrack.
 flow="inport == \"lsp1\" && ${flow_eth} && ${flow_ip} && ${flow_udp}"
 AT_CHECK_UNQUOTED([ovn_trace --ct new --ct new --minimal ls "${flow}"], [0], 
[dnl
+ct_lb_mark {
+    ct_lb_mark {
+        reg0[[6]] = 0;
+        reg0[[12]] = 0;
+        ct_lb_mark /* default (use --ct to customize) */ {
+            output("lsp2");
+        };
+    };
+};
+])
+
+# Fragmented UDP packets should go to conntrack.
+flow="inport == \"lsp1\" && ${flow_eth} && ${flow_ip} && ${flow_udp} && 
ip.is_frag"
+AT_CHECK_UNQUOTED([ovn_trace --ct new --ct new --minimal ls "${flow}"], [0], 
[dnl
 ct_lb_mark {
     reg1[[16..23]] = 17;
     reg1[[0..15]] = 80;
@@ -4378,6 +4390,20 @@ output("lsp2");
 # UDP packets still go to conntrack.
 flow="inport == \"lsp1\" && ${flow_eth} && ${flow_ip} && ${flow_udp}"
 AT_CHECK_UNQUOTED([ovn_trace --ct new --ct new --minimal ls "${flow}"], [0], 
[dnl
+ct_lb_mark {
+    ct_lb_mark {
+        reg0[[6]] = 0;
+        reg0[[12]] = 0;
+        ct_lb_mark /* default (use --ct to customize) */ {
+            output("lsp2");
+        };
+    };
+};
+])
+
+# Fragmented UDP packets still go to conntrack.
+flow="inport == \"lsp1\" && ${flow_eth} && ${flow_ip} && ${flow_udp} && 
ip.is_frag"
+AT_CHECK_UNQUOTED([ovn_trace --ct new --ct new --minimal ls "${flow}"], [0], 
[dnl
 ct_lb_mark {
     reg1[[16..23]] = 17;
     reg1[[0..15]] = 80;
@@ -4478,8 +4504,6 @@ check ovn-nbctl --wait=sb sync
 flow="inport == \"lsp1\" && ${flow_eth} && ${flow_ip} && ${flow_tcp}"
 AT_CHECK_UNQUOTED([ovn_trace --ct new --ct new --minimal ls "${flow}"], [0], 
[dnl
 ct_lb_mark {
-    reg1[[16..23]] = 6;
-    reg1[[0..15]] = 80;
     ct_lb_mark {
         reg0[[6]] = 0;
         reg0[[12]] = 0;
@@ -4493,6 +4517,20 @@ ct_lb_mark {
 # UDP packets should go to conntrack.
 flow="inport == \"lsp1\" && ${flow_eth} && ${flow_ip} && ${flow_udp}"
 AT_CHECK_UNQUOTED([ovn_trace --ct new --ct new --minimal ls "${flow}"], [0], 
[dnl
+ct_lb_mark {
+    ct_lb_mark {
+        reg0[[6]] = 0;
+        reg0[[12]] = 0;
+        ct_lb_mark /* default (use --ct to customize) */ {
+            output("lsp2");
+        };
+    };
+};
+])
+
+# Fragmented UDP packets should go to conntrack.
+flow="inport == \"lsp1\" && ${flow_eth} && ${flow_ip} && ${flow_udp} && 
ip.is_frag"
+AT_CHECK_UNQUOTED([ovn_trace --ct new --ct new --minimal ls "${flow}"], [0], 
[dnl
 ct_lb_mark {
     reg1[[16..23]] = 17;
     reg1[[0..15]] = 80;
@@ -4521,6 +4559,20 @@ output("lsp2");
 # UDP packets still go to conntrack.
 flow="inport == \"lsp1\" && ${flow_eth} && ${flow_ip} && ${flow_udp}"
 AT_CHECK_UNQUOTED([ovn_trace --ct new --ct new --minimal ls "${flow}"], [0], 
[dnl
+ct_lb_mark {
+    ct_lb_mark {
+        reg0[[6]] = 0;
+        reg0[[12]] = 0;
+        ct_lb_mark /* default (use --ct to customize) */ {
+            output("lsp2");
+        };
+    };
+};
+])
+
+# Fragmented UDP packets still go to conntrack.
+flow="inport == \"lsp1\" && ${flow_eth} && ${flow_ip} && ${flow_udp} && 
ip.is_frag"
+AT_CHECK_UNQUOTED([ovn_trace --ct new --ct new --minimal ls "${flow}"], [0], 
[dnl
 ct_lb_mark {
     reg1[[16..23]] = 17;
     reg1[[0..15]] = 80;
@@ -8580,8 +8632,6 @@ flow="eth.dst == 00:00:00:00:01:00 && inport == 
\"rtr-ls\" && ip4.src == 42.42.4
 ovn_trace --ct new "${flow}" --lb-dst 
42.42.42.42:4242<http://42.42.42.42:4242/>
 AT_CHECK_UNQUOTED([ovn_trace --ct new --minimal "${flow}" --lb-dst 
42.42.42.42:4242<http://42.42.42.42:4242/>], [0], [dnl
 ct_dnat /* assuming no un-dnat entry, so no change */ {
-    reg1[[16..23]] = 6;
-    reg1[[0..15]] = 4343;
     ct_lb_mark /* default (use --ct to customize) */ {
         ip.ttl--;
         eth.src = 00:00:00:00:01:00;
diff --git a/tests/system-ovn.at<http://system-ovn.at/> 
b/tests/system-ovn.at<http://system-ovn.at/>
index 13e62bf9ba..3239d709b4 100644
--- a/tests/system-ovn.at<http://system-ovn.at/>
+++ b/tests/system-ovn.at<http://system-ovn.at/>
@@ -23978,3 +23978,95 @@ OVS_TRAFFIC_VSWITCHD_STOP(["/failed to query port 
patch-.*/d

 AT_CLEANUP
 ])
+
+OVN_FOR_EACH_NORTHD([
+AT_SETUP([CT extraction limited to LB VIPs])
+AT_KEYWORDS([ovnlb ct_extract])
+AT_SKIP_IF([test $HAVE_NCAT = no])
+AT_SKIP_IF([test $HAVE_TCPDUMP = no])
+
+dnl Test that the CT_EXTRACT pipeline stage currently matches doesn't
+dnl generate ct_tuple4 megaflow matches for non fragments.
+
+ovn_start
+
+OVS_TRAFFIC_VSWITCHD_START()
+ADD_BR([br-int])
+
+check ovs-vsctl                                                                
         \
+        -- set Open_vSwitch . external-ids:system-id=hv1                       
         \
+        -- set Open_vSwitch . 
external-ids:ovn-remote=unix:$ovs_base/ovn-sb/ovn-sb.sock \
+        -- set Open_vSwitch . external-ids:ovn-encap-type=geneve               
         \
+        -- set Open_vSwitch . external-ids:ovn-encap-ip=169.0.0.1              
         \
+        -- set bridge br-int fail-mode=secure other-config:disable-in-band=true
+
+start_daemon ovn-controller
+
+check ovn-nbctl                                                \
+    -- lr-add lr0                                              \
+      -- lrp-add lr0 lrp aa:bb:cc:dd:ee:03 10.0.0.3/24<http://10.0.0.3/24>     
    \
+    -- ls-add ls0                                              \
+      -- lsp-add ls0 lsp0                                      \
+        -- lsp-set-addresses lsp0 "aa:bb:cc:dd:ee:01 10.0.0.1" \
+      -- lsp-add ls0 lsp1                                      \
+        -- lsp-set-addresses lsp1 "aa:bb:cc:dd:ee:02 10.0.0.2" \
+      -- lsp-add-router-port ls0 ls0-lr0 lrp
+
+check ovn-nbctl lb-add lb0 10.0.1.100:5000<http://10.0.1.100:5000/> 
10.0.0.2:6000<http://10.0.0.2:6000/> udp
+check ovn-nbctl ls-lb-add ls0 lb0
+
+ADD_NAMESPACES(lsp0, lsp1)
+ADD_VETH(lsp0, lsp0, br-int, "10.0.0.1/24<http://10.0.0.1/24>", 
"aa:bb:cc:dd:ee:01", "10.0.0.3")
+ADD_VETH(lsp1, lsp1, br-int, "10.0.0.2/24<http://10.0.0.2/24>", 
"aa:bb:cc:dd:ee:02", "10.0.0.3")
+
+wait_for_ports_up lsp0 lsp1
+check ovn-nbctl --wait=hv sync
+
+dnl Verify basic connectivity before testing megaflows.
+NS_CHECK_EXEC([lsp0], [ping -q -c 3 -i 0.3 -w 2 10.0.0.2 | FORMAT_PING], \
+[0], [dnl
+3 packets transmitted, 3 received, 0% packet loss, time 0ms
+])
+
+check ovs-appctl revalidator/pause
+on_exit 'ovs-appctl revalidator/resume'
+
+dnl --- Unfragmented unidirectional UDP to non-VIP must not have ct_tuple4 ---
+dnl
+dnl Start a UDP listener on lsp1 to absorb traffic.  Start a continuous
+dnl unidirectional UDP sender on lsp0 to port 4791 (a non-VIP port).
+dnl The listener does not send replies, so every packet stays 
ct.new<http://ct.new/>.
+dnl There should be no ct_tuple4 matches in the megaflows for this traffic.
+NETNS_DAEMONIZE([lsp1], [ncat -u -l 4791 > /dev/null], [udp-listener.pid])
+NETNS_DAEMONIZE([lsp0], [bash -c 'while true; do echo test; sleep 0.2; done | 
ncat -u 10.0.0.2 4791'], [udp-sender.pid])
+
+dnl Wait until the datapath has a cached flow for the UDP traffic we sent.
+OVS_WAIT_UNTIL([ovs-appctl dpctl/dump-flows | grep -q 'dst=4791'])
+
+ovs-appctl dpctl/dump-flows > dpflows
+AT_CAPTURE_FILE([dpflows])
+
+AT_CHECK([grep 'dst=4791' dpflows | grep -q 'ct_tuple4'], [1])
+
+dnl --- UDP to VIP is DNAT'd correctly ---
+dnl
+NETNS_DAEMONIZE([lsp1], [ncat -u -l 10.0.0.2 6000], [lsp1_udp.pid])
+NETNS_START_TCPDUMP([lsp1], [-nn -c 1 -i lsp1 'udp and dst port 6000 and dst 
host 10.0.0.2'], [lb-dnat])
+
+NS_CHECK_EXEC([lsp0], [echo test | ncat -u 10.0.1.100 5000], [ignore], 
[ignore], [ignore])
+
+OVS_WAIT_UNTIL([
+    total_pkts=$(cat lb-dnat.tcpdump | wc -l)
+    test "${total_pkts}" -ge 1
+])
+
+check ovs-appctl revalidator/resume
+
+OVN_CLEANUP_CONTROLLER([hv1])
+OVN_CLEANUP_NORTHD
+
+as
+OVS_TRAFFIC_VSWITCHD_STOP(["/failed to query port patch-.*/d
+/connection dropped.*/d"])
+AT_CLEANUP
+])
--
2.55.0


Looks good to me, thanks.
Acked-by: Ales Musil <[email protected]<mailto:[email protected]>>
_______________________________________________
dev mailing list
[email protected]
https://mail.openvswitch.org/mailman/listinfo/ovs-dev

Reply via email to