From 963d7d34d35d125d36265561487e1cbf1b76a762 Mon Sep 17 00:00:00 2001 From: Andrew Lukoshko Date: Wed, 11 Mar 2026 16:22:58 +0000 Subject: [PATCH] Add autopatch config for firewalld (EL8) Batch ipset elements in nftables set_restore to avoid O(n^2) insertion cost on RHEL/AlmaLinux 8. Adds the patch as Patch1000, bumps the release to .alma.1, and adds a changelog entry. --- config.yaml | 18 ++++ ...ch-ipset-elements-to-avoid-O-n2-inse.patch | 85 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 config.yaml create mode 100644 files/1000-fix-nftables-batch-ipset-elements-to-avoid-O-n2-inse.patch diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..fe02ac0 --- /dev/null +++ b/config.yaml @@ -0,0 +1,18 @@ +actions: + - replace: + - target: "spec" + find: "Release: 10%{?dist}" + replace: "Release: 10%{?dist}.alma.1" + count: 1 + + - changelog_entry: + - name: "Andrew Lukoshko" + email: "alukoshko@almalinux.org" + line: + - "fix(nftables): batch ipset elements to avoid O(n^2) insertion cost" + - "Resolves: almalinux/almalinux-deploy#186" + + - add_files: + - type: "patch" + name: "1000-fix-nftables-batch-ipset-elements-to-avoid-O-n2-inse.patch" + number: 1000 diff --git a/files/1000-fix-nftables-batch-ipset-elements-to-avoid-O-n2-inse.patch b/files/1000-fix-nftables-batch-ipset-elements-to-avoid-O-n2-inse.patch new file mode 100644 index 0000000..d5cbd97 --- /dev/null +++ b/files/1000-fix-nftables-batch-ipset-elements-to-avoid-O-n2-inse.patch @@ -0,0 +1,85 @@ +From ad48eb571ff4966c3ebfbbf12fe8adf293658e20 Mon Sep 17 00:00:00 2001 +From: Andrew Lukoshko +Date: Tue, 10 Mar 2026 13:30:54 +0100 +Subject: fix(nftables): batch ipset elements in set_restore to avoid O(n^2) + insertion cost + +set_restore() currently creates one "add element" nft operation per +ipset entry, then sends them in chunks of 1000 operations. On older +nftables/kernel combinations (e.g. nftables 1.0.4 / kernel 4.18 +shipped in RHEL 8) each incremental element insertion into an interval +set has O(n) cost proportional to the current set size, making the +overall complexity O(n^2). + +With 12,000 ipset entries this causes firewall-cmd --reload to take +~80 seconds on RHEL 8 with the nftables backend. Even on newer +systems (nftables 1.0.9 / kernel 5.14) batching yields a ~20% +improvement. + +This patch accumulates all element fragments and creates batched +"add element" operations - one per chunk of 1000 elements - instead +of one per entry. This lets nftables handle bulk element insertion +natively and reduces nft operations from N to ceil(N/1000). + +Benchmark (12k ipset entries, nftables backend): + RHEL 8 (nftables 1.0.4, kernel 4.18): ~79,100ms -> ~2,500ms + RHEL 9 (nftables 1.0.9, kernel 5.14): ~1,560ms -> ~1,240ms + +Backport of upstream PR #1544 for firewalld 0.9.11 (RHEL/AlmaLinux 8). +NOTE: erig0's original RHEL-8 backport was missing rules.clear() +between chunks, causing "No buffer space available" on large ipsets. +This version includes the fix. + +Fixes #933 +--- + src/firewall/core/nftables.py | 23 ++++++++++++----------- + 1 file changed, 12 insertions(+), 11 deletions(-) + +diff --git a/src/firewall/core/nftables.py b/src/firewall/core/nftables.py +index XXXXXXX..XXXXXXX 100644 +--- a/src/firewall/core/nftables.py ++++ b/src/firewall/core/nftables.py +@@ -1896,14 +1896,19 @@ class nftables(object): + fragment.append(entry_tokens[i]) + return [{"concat": fragment}] if len(type_format) > 1 else fragment + +- def build_set_add_rules(self, name, entry): ++ def build_set_add_rules(self, name, entries): + rules = [] +- element = self._set_entry_fragment(name, entry) ++ elements = [] ++ if not isinstance(entries, (list, tuple)): ++ entries = [entries] ++ for element in entries: ++ elements.extend(self._set_entry_fragment(name, element)) ++ + for family in ["inet", "ip", "ip6"]: + rules.append({"add": {"element": {"family": family, + "table": TABLE_NAME, + "name": name, +- "elem": element}}}) ++ "elem": elements}}}) + return rules + + def set_add(self, name, entry): +@@ -1952,13 +1957,9 @@ class nftables(object): + rules.extend(self.build_set_flush_rules(set_name)) + + # avoid large memory usage by chunking the entries +- chunk = 0 +- for entry in entries: +- rules.extend(self.build_set_add_rules(set_name, entry)) +- chunk += 1 +- if chunk >= 1000: +- self.set_rules(rules, self._fw.get_log_denied()) +- rules.clear() +- chunk = 0 +- else: ++ for i in range(0, len(entries), 1000): ++ rules.extend(self.build_set_add_rules(set_name, entries[i : i + 1000])) ++ self.set_rules(rules, self._fw.get_log_denied()) ++ rules.clear() ++ else: + self.set_rules(rules, self._fw.get_log_denied()) +-- +2.43.5