This is an automated email from the ASF dual-hosted git repository.

anton-vinogradov pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ignite.git


The following commit(s) were added to refs/heads/master by this push:
     new 0d65ca707c1 IGNITE-28932 [ducktests] Make garbage collector selectable 
via globals (#13413)
0d65ca707c1 is described below

commit 0d65ca707c1dc217be4d9e3570cebee5f844d34a
Author: Maksim Davydov <[email protected]>
AuthorDate: Fri Sep 11 18:01:22 2026 +0300

    IGNITE-28932 [ducktests] Make garbage collector selectable via globals 
(#13413)
---
 modules/ducktests/README.md                        |  49 ++++++
 .../tests/checks/utils/check_gc_params.py          | 142 +++++++++++++++
 .../tests/checks/utils/check_ignite_spec.py        | 190 ++++++++++++++++++++-
 .../tests/checks/utils/check_jvm_settings.py       |  64 ++++++-
 .../ducktests/tests/docker/requirements-dev.txt    |   3 +-
 modules/ducktests/tests/docker/requirements.txt    |   2 +-
 .../utils/cdc/ignite_to_kafka_cdc_helper.py        |   9 +-
 .../services/utils/cdc/kafka_to_ignite.py          |   6 +-
 .../tests/ignitetest/services/utils/gc_params.py   |  88 ++++++++++
 .../ignitetest/services/utils/ignite_aware.py      |  13 +-
 .../tests/ignitetest/services/utils/ignite_spec.py |  79 +++++++--
 .../tests/ignitetest/services/utils/jvm_utils.py   | 128 ++++++++++++--
 .../ignitetest/services/utils/metrics/metrics.py   |   4 +-
 modules/ducktests/tests/tox.ini                    |   2 +-
 14 files changed, 741 insertions(+), 38 deletions(-)

diff --git a/modules/ducktests/README.md b/modules/ducktests/README.md
index b52c22c8a02..4f0ccc3dde1 100644
--- a/modules/ducktests/README.md
+++ b/modules/ducktests/README.md
@@ -185,6 +185,7 @@ You can modify test environments at execution time using 
global flags injected t
 
 | Global Parameter Key | Definition | Example Configuration |
 |---------------------|------------|----------------------|
+| **gc** | Garbage collector to run nodes with, selectable independently for 
the `server` and `client` roles. Default is `G1` for both. See [Garbage 
Collector Selection](#garbage-collector-selection) below. | ```{"gc": 
{"server": "ZGC", "client": "SERIAL"}}``` |
 | **jfr_enabled** | Boolean flag to enable Java Flight Recorder for 
performance profiling. Default is False. | ```{"jfr_enabled": true}``` |
 | **safepoint_log_enabled** | Boolean flag to enable safepoint logging for 
debugging JVM behavior. Default is False. | ```{"safepoint_log_enabled": 
true}``` |
 | **jmx_remote** | JMX remote monitoring configuration with nested parameters. 
Enabled flag controls remote JMX access, port specifies the listening port 
(default is 1098). | ```{"jmx_remote": {"enabled": true, "port": 1099}}``` |
@@ -229,6 +230,51 @@ You can target specific cross-product version 
compatibility combinations inside
 ```
 
 
+### Garbage Collector Selection
+
+The collector is chosen with the `gc` global. It is *mutually-exclusive group 
replacement*: a collector and the tuning flags that are meaningful for it 
travel together, so selecting one swaps the whole group. This is why it cannot 
be done by passing `-XX:+UseZGC` in `jvm_opts` — that leaves two selectors on 
the command line and the JVM aborts at startup with "Multiple garbage 
collectors selected".
+
+Three shapes are accepted:
+
+```bash
+# Both roles
+--global-json '{"gc": "ZGC"}'
+
+# Servers only; clients keep the default
+--global-json '{"gc": {"server": "ZGC"}}'
+
+# Per role
+--global-json '{"gc": {"server": "ZGC", "client": "SERIAL"}}'
+
+# Raw JVM options -- escape hatch, bypasses the registry and its validation
+--global-json '{"gc": {"server": ["-XX:+UseZGC", "-XX:SoftMaxHeapSize=2G"]}}'
+```
+
+Profile names are case-insensitive (`"zgc"` == `"ZGC"`). An unknown name fails 
immediately with the list of valid names rather than silently falling back.
+
+| Profile | Options set |
+|---------|-------------|
+| **G1** (default) | `-XX:+UseG1GC`, `-XX:MaxGCPauseMillis=100`, 
`-XX:ConcGCThreads`, `-XX:ParallelGCThreads`, `-XX:+UseStringDeduplication` |
+| **PARALLEL** | `-XX:+UseParallelGC`, `-XX:ParallelGCThreads` — deliberately 
no `MaxGCPauseMillis`, which would flip ParallelGC into adaptive pause-goal 
sizing |
+| **SERIAL** | `-XX:+UseSerialGC` |
+| **ZGC** | `-XX:+UseZGC`, `-XX:ConcGCThreads`, `-XX:ParallelGCThreads` |
+| **SHENANDOAH** | `-XX:+UseShenandoahGC`, `-XX:ConcGCThreads`, 
`-XX:ParallelGCThreads` — OpenJDK builds only, absent from Oracle JDK |
+
+`-XX:+UseStringDeduplication` is part of the G1 profile because it is G1-only 
through JDK 17; it is not applied under any other collector.
+
+**Roles** are determined semantically, not by service class: a service is a 
`server` only if it is a full Ignite node that is not in client mode. 
Client-mode `IgniteService`s, application services, thin clients, thin JDBC and 
the Kafka-CDC `kafka-to-ignite` utility all resolve as `client`.
+
+**Precedence**, lowest to highest:
+
+```
+G1 default  <  gc global for the role  <  jvm_opts passed by the test
+```
+
+Caveats:
+* A service constructed with `merge_with_default=False` drops every default, 
so the `gc` global does not reach it — its collector comes from `jvm_opts` 
alone. A warning is logged when this happens.
+* The raw-list form is used verbatim and is *not* validated, so two selectors 
in one list still reach the JVM.
+* Conflicting selectors from any other route raise a Python exception when the 
service is constructed, rather than failing later in a remote JVM.
+
 ### Diagnostics & Performance Utilities
 ```bash
 # Enable Java Flight Recorder (JFR) tracing
@@ -236,6 +282,9 @@ You can target specific cross-product version compatibility 
combinations inside
 
 # Enable JVM Safepoints performance logging
 --global-json '{"safepoint_log_enabled": true}'
+
+# Run servers under ZGC and clients under SerialGC
+--global-json '{"gc": {"server": "ZGC", "client": "SERIAL"}}'
 ```
 
 ### Demo Mode (Breakpoints)
diff --git a/modules/ducktests/tests/checks/utils/check_gc_params.py 
b/modules/ducktests/tests/checks/utils/check_gc_params.py
new file mode 100644
index 00000000000..d83d9fee40d
--- /dev/null
+++ b/modules/ducktests/tests/checks/utils/check_gc_params.py
@@ -0,0 +1,142 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Checks resolution of the 'gc' global into garbage collector options.
+"""
+
+import pytest
+
+from ignitetest.services.utils.gc_params import CLIENT_ROLE, SERVER_ROLE, 
resolve_gc_settings
+from ignitetest.services.utils.jvm_utils import GC_PROFILES, GC_G1, GC_SERIAL, 
GC_Z
+
+
+class CheckGcParams:
+    """
+    Checks the 'gc' global parser.
+    """
+
+    @pytest.mark.parametrize('role', [SERVER_ROLE, CLIENT_ROLE])
+    def check_default__when_gc_global_absent(self, role):
+        """
+        The no-op path: without the global both roles get G1.
+        """
+        assert resolve_gc_settings({}, role) == GC_PROFILES[GC_G1]
+        assert resolve_gc_settings({"cluster_size": 3}, role) == 
GC_PROFILES[GC_G1]
+        assert resolve_gc_settings(None, role) == GC_PROFILES[GC_G1]
+
+    @pytest.mark.parametrize('role', [SERVER_ROLE, CLIENT_ROLE])
+    def check_bare_string__applies_to_both_roles(self, role):
+        """
+        Bare string is sugar for {"server": X, "client": X}.
+        """
+        assert resolve_gc_settings({"gc": "ZGC"}, role) == GC_PROFILES[GC_Z]
+
+    def check_per_role_mapping(self):
+        """
+        Each role resolves independently.
+        """
+        _globals = {"gc": {"server": "ZGC", "client": "SERIAL"}}
+
+        assert resolve_gc_settings(_globals, SERVER_ROLE) == GC_PROFILES[GC_Z]
+        assert resolve_gc_settings(_globals, CLIENT_ROLE) == 
GC_PROFILES[GC_SERIAL]
+
+    def check_omitted_role__falls_back_to_default(self):
+        """
+        A role missing from the mapping keeps the default collector.
+        """
+        _globals = {"gc": {"server": "ZGC"}}
+
+        assert resolve_gc_settings(_globals, SERVER_ROLE) == GC_PROFILES[GC_Z]
+        assert resolve_gc_settings(_globals, CLIENT_ROLE) == GC_PROFILES[GC_G1]
+
+    @pytest.mark.parametrize('name', ["zgc", "ZGC", "Zgc", "shenandoah", 
"Parallel"])
+    def check_profile_names__are_case_insensitive(self, name):
+        """
+        Profile names are matched case-insensitively.
+        """
+        assert resolve_gc_settings({"gc": name}, SERVER_ROLE) == 
GC_PROFILES[name.upper()]
+
+    def check_raw_list__is_passed_through_verbatim(self):
+        """
+        A list bypasses the registry entirely -- the documented escape hatch.
+        """
+        raw = ["-XX:+UseZGC", "-XX:SoftMaxHeapSize=2G"]
+
+        assert resolve_gc_settings({"gc": raw}, SERVER_ROLE) == raw
+        assert resolve_gc_settings({"gc": {"server": raw}}, SERVER_ROLE) == raw
+        assert resolve_gc_settings({"gc": {"server": raw}}, CLIENT_ROLE) == 
GC_PROFILES[GC_G1]
+
+    def check_raw_list__is_not_aliased_to_the_globals(self):
+        """
+        The caller must not be able to mutate the globals through the returned 
list.
+        """
+        raw = ["-XX:+UseZGC"]
+
+        resolved = resolve_gc_settings({"gc": raw}, SERVER_ROLE)
+        resolved.append("-XX:SoftMaxHeapSize=2G")
+
+        assert raw == ["-XX:+UseZGC"]
+
+    def check_profile__is_not_aliased_to_the_registry(self):
+        """
+        Mutating a resolved profile must not corrupt the registry for the next 
service.
+        """
+        resolved = resolve_gc_settings({"gc": "SERIAL"}, SERVER_ROLE)
+        resolved.append("-XX:SoftMaxHeapSize=2G")
+
+        assert resolve_gc_settings({"gc": "SERIAL"}, SERVER_ROLE) == 
GC_PROFILES[GC_SERIAL]
+
+    def check_unknown_profile__raises_and_lists_valid_names(self):
+        """
+        An unknown name fails loudly instead of silently falling back to G1.
+        """
+        with pytest.raises(ValueError) as err:
+            resolve_gc_settings({"gc": "CMS"}, SERVER_ROLE)
+
+        for name in GC_PROFILES:
+            assert name in str(err.value)
+
+    def check_unexpected_value__raises(self):
+        """
+        Neither a profile name, nor raw options, nor a role mapping.
+        """
+        with pytest.raises(ValueError):
+            resolve_gc_settings({"gc": 42}, SERVER_ROLE)
+
+        with pytest.raises(ValueError):
+            resolve_gc_settings({"gc": {"server": 42}}, SERVER_ROLE)
+
+    @pytest.mark.parametrize('name', [name for name in GC_PROFILES if name != 
GC_G1])
+    def check_non_g1_profiles__carry_no_g1_only_flags(self, name):
+        """
+        MaxGCPauseMillis flips ParallelGC into adaptive pause-goal sizing, and 
UseStringDeduplication is
+        G1-only until JDK 18. Neither may leak into another collector's 
profile.
+        """
+        resolved = resolve_gc_settings({"gc": name}, SERVER_ROLE)
+
+        assert not any("MaxGCPauseMillis" in opt for opt in resolved)
+        assert not any("UseStringDeduplication" in opt for opt in resolved)
+
+    @pytest.mark.parametrize('name', list(GC_PROFILES))
+    def check_every_profile__selects_exactly_one_collector(self, name):
+        """
+        A profile is a mutually exclusive group -- exactly one selector, no 
spaces or quotes (the options
+        are interpolated into a remote shell command).
+        """
+        resolved = resolve_gc_settings({"gc": name}, SERVER_ROLE)
+
+        assert len([opt for opt in resolved if opt.startswith("-XX:+Use") and 
opt.endswith("GC")]) == 1
+        assert not any(" " in opt or "'" in opt or '"' in opt for opt in 
resolved)
diff --git a/modules/ducktests/tests/checks/utils/check_ignite_spec.py 
b/modules/ducktests/tests/checks/utils/check_ignite_spec.py
index b60fce333e1..1e2cc4d3920 100644
--- a/modules/ducktests/tests/checks/utils/check_ignite_spec.py
+++ b/modules/ducktests/tests/checks/utils/check_ignite_spec.py
@@ -21,12 +21,14 @@ from unittest.mock import Mock
 
 import pytest
 
-from ignitetest.services.utils.ignite_spec import IgniteApplicationSpec
+from ignitetest.services.utils import IgniteServiceType
+from ignitetest.services.utils.gc_params import CLIENT_ROLE, SERVER_ROLE
+from ignitetest.services.utils.ignite_spec import IgniteApplicationSpec, 
IgniteNodeSpec, service_role
+from ignitetest.services.utils.jvm_utils import GC_PROFILES, GC_G1, 
MultipleGcSelectedError
 from ignitetest.utils.ignite_test import JFR_ENABLED
 
 
[email protected]
-def service():
+def mock_service(service_type=IgniteServiceType.NODE, client_mode=False):
     """
     Create mock of service.
     """
@@ -35,10 +37,28 @@ def service():
     service.persistent_root = ''
     service.context.globals = {"cluster_size": 1}
     service.log_config_file = ''
+    service.config.service_type = service_type
+    service.config.client_mode = client_mode
 
     return service
 
 
[email protected]
+def service():
+    """
+    Mock of a server node service.
+    """
+    return mock_service()
+
+
[email protected]
+def client_service():
+    """
+    Mock of a client node service.
+    """
+    return mock_service(client_mode=True)
+
+
 """
 Checks that the JVM options passed via constructor are not overriden by the 
default ones.
 """
@@ -103,3 +123,167 @@ def 
check_colon_options__go_after_default_ones_and_overwrite_them__if_passed_via
     assert "-Xlog:gc:/some-non-default-path/gc.log" in spec.jvm_opts
     assert default_gc_log in spec.jvm_opts
     assert spec.jvm_opts.index("-Xlog:gc:/some-non-default-path/gc.log") > 
spec.jvm_opts.index(default_gc_log)
+
+
+"""
+Checks GC selection via the 'gc' global.
+"""
+
+
[email protected](
+    'service_type,client_mode,expected',
+    [
+        [IgniteServiceType.NODE, False, SERVER_ROLE],
+        [IgniteServiceType.NODE, True, CLIENT_ROLE],       # IgniteService can 
run in client mode
+        [IgniteServiceType.THIN_CLIENT, None, CLIENT_ROLE],  # client_mode 
absent on thin configs
+        [IgniteServiceType.THIN_JDBC, None, CLIENT_ROLE],
+        [IgniteServiceType.NONE, None, CLIENT_ROLE],
+    ]
+)
+def check_service_role__is_determined_semantically(service_type, client_mode, 
expected):
+    assert service_role(mock_service(service_type, client_mode)) == expected
+
+
+def check_g1__is_used__if_gc_global_is_absent(service):
+    spec = IgniteNodeSpec(service)
+    for opt in GC_PROFILES[GC_G1]:
+        assert opt in spec.jvm_opts
+
+
+def check_gc_global__applies_to_both_roles__if_passed_as_bare_string(service, 
client_service):
+    service.context.globals["gc"] = "ZGC"
+    client_service.context.globals["gc"] = "ZGC"
+
+    for spec in (IgniteNodeSpec(service), IgniteNodeSpec(client_service)):
+        assert "-XX:+UseZGC" in spec.jvm_opts
+        assert "-XX:+UseG1GC" not in spec.jvm_opts
+
+
+def check_gc_global__applies_per_role__if_passed_as_mapping(service, 
client_service):
+    gc_global = {"server": "ZGC", "client": "SERIAL"}
+    service.context.globals["gc"] = gc_global
+    client_service.context.globals["gc"] = gc_global
+
+    server_spec = IgniteNodeSpec(service)
+    client_spec = IgniteNodeSpec(client_service)
+
+    assert "-XX:+UseZGC" in server_spec.jvm_opts
+    assert "-XX:+UseSerialGC" in client_spec.jvm_opts
+
+    for spec in (server_spec, client_spec):
+        assert "-XX:+UseG1GC" not in spec.jvm_opts
+        assert "-XX:+UseStringDeduplication" not in spec.jvm_opts
+        assert not any("MaxGCPauseMillis" in opt for opt in spec.jvm_opts)
+
+
+def 
check_string_deduplication__is_not_applied__under_non_g1_collectors(service):
+    assert "-XX:+UseStringDeduplication" in IgniteNodeSpec(service).jvm_opts
+
+    service.context.globals["gc"] = "PARALLEL"
+    assert "-XX:+UseStringDeduplication" not in 
IgniteNodeSpec(service).jvm_opts
+
+
+def 
check_conflicting_gc_selectors__raise__if_gc_is_also_passed_via_jvm_opts(service):
+    service.context.globals["gc"] = "ZGC"
+
+    with pytest.raises(MultipleGcSelectedError) as err:
+        IgniteNodeSpec(service, jvm_opts=["-XX:+UseSerialGC"])
+
+    assert "-XX:+UseZGC" in str(err.value)
+    assert "-XX:+UseSerialGC" in str(err.value)
+
+
+def 
check_conflicting_gc_selectors__raise__if_both_are_passed_via_jvm_opts(service):
+    with pytest.raises(MultipleGcSelectedError):
+        IgniteNodeSpec(service, jvm_opts=["-XX:+UseSerialGC", 
"-XX:+UseParallelGC"], merge_with_default=False)
+
+
+def check_disabled_gc_selector__does_not_conflict(service):
+    """
+    -XX:-UseG1GC turns G1 off, so the collector picked afterwards is the only 
one enabled.
+    """
+    spec = IgniteNodeSpec(service, jvm_opts=["-XX:-UseG1GC", 
"-XX:+UseSerialGC"])
+
+    assert "-XX:+UseSerialGC" in spec.jvm_opts
+
+
+def check_gc_global__does_not_apply__if_merge_with_default_is_false(service):
+    service.context.globals["gc"] = "ZGC"
+
+    spec = IgniteNodeSpec(service, jvm_opts="-XX:+UseSerialGC", 
merge_with_default=False)
+
+    assert spec.jvm_opts == ["-XX:+UseSerialGC"]
+    service.logger.warning.assert_called_once()
+
+
+"""
+Checks that a spec keeps the caller's delta separate from its own resolution, 
so another service can
+inherit the delta without inheriting role-dependent options. See the CDC path.
+"""
+
+
+def check_user_jvm_opts__holds_the_delta__not_the_resolution(service):
+    spec = IgniteNodeSpec(service, jvm_opts=["-Xmx8G", "-DFOO=bar"])
+
+    assert spec.user_jvm_opts == ["-Xmx8G", "-DFOO=bar"]
+    assert spec.merge_with_default is True
+
+    assert "-Xmx8G" in spec.jvm_opts
+    assert "-XX:+UseG1GC" in spec.jvm_opts  # resolution is richer than the 
delta
+
+
+def check_user_jvm_opts__splits_a_string_delta(service):
+    assert IgniteNodeSpec(service, jvm_opts="-Xmx8G -ea").user_jvm_opts == 
["-Xmx8G", "-ea"]
+
+
[email protected]('merge_with_default', [True, False])
+def check_rebuild_as__reproduces_the_original_resolution(service, 
merge_with_default):
+    spec = IgniteNodeSpec(service, jvm_opts=["-Xmx8G"], 
merge_with_default=merge_with_default)
+
+    assert spec.rebuild_as(IgniteNodeSpec).jvm_opts == spec.jvm_opts
+
+
+def check_rebuild_as__does_not_leak_gc_across_roles(client_service):
+    """
+    The CDC regression: a client-role service inheriting a server cluster's 
delta must keep its own
+    collector, and must end up with exactly one selector.
+    """
+    server = mock_service()
+    server.context.globals["gc"] = {"server": "ZGC", "client": "SERIAL"}
+    client_service.context.globals["gc"] = {"server": "ZGC", "client": 
"SERIAL"}
+
+    server_spec = IgniteNodeSpec(server, jvm_opts=["-Xmx8G"])
+
+    # What ignite_to_kafka_cdc_helper does: hand the destination cluster's 
delta to a client service.
+    inherited = IgniteNodeSpec(client_service, 
jvm_opts=server_spec.user_jvm_opts)
+
+    assert "-XX:+UseZGC" in server_spec.jvm_opts
+    assert "-XX:+UseSerialGC" in inherited.jvm_opts
+    assert "-XX:+UseZGC" not in inherited.jvm_opts
+    assert "-Xmx8G" in inherited.jvm_opts  # the delta itself is still 
inherited
+
+    for spec in (server_spec, inherited):
+        assert len([opt for opt in spec.jvm_opts if opt.startswith("-XX:+Use") 
and opt.endswith("GC")]) == 1
+
+
+"""
+Checks assembly of spec-specific defaults.
+"""
+
+
+def check_application_spec__has_matched_heap_bounds(service):
+    spec = IgniteApplicationSpec(service)
+
+    assert "-Xmx1G" in spec.jvm_opts
+    assert "-Xms1G" in spec.jvm_opts
+    assert len([opt for opt in spec.jvm_opts if opt.startswith("-Xmx")]) == 1
+    assert len([opt for opt in spec.jvm_opts if opt.startswith("-Xms")]) == 1
+
+
+def check_heap_override__from_jvm_opts__still_wins(service):
+    spec = IgniteApplicationSpec(service, jvm_opts=["-Xmx4G", "-Xms4G"])
+
+    assert "-Xmx4G" in spec.jvm_opts
+    assert "-Xms4G" in spec.jvm_opts
+    assert "-Xmx1G" not in spec.jvm_opts
+    assert "-Xms1G" not in spec.jvm_opts
diff --git a/modules/ducktests/tests/checks/utils/check_jvm_settings.py 
b/modules/ducktests/tests/checks/utils/check_jvm_settings.py
index 0fb44bef9dd..b15000d6687 100644
--- a/modules/ducktests/tests/checks/utils/check_jvm_settings.py
+++ b/modules/ducktests/tests/checks/utils/check_jvm_settings.py
@@ -19,7 +19,8 @@ Checks JVM settings.
 
 import pytest
 
-from ignitetest.services.utils.jvm_utils import create_jvm_settings, 
merge_jvm_settings, DEFAULT_HEAP
+from ignitetest.services.utils.jvm_utils import create_jvm_settings, 
merge_jvm_settings, validate_gc_settings, \
+    DEFAULT_HEAP, GC_PROFILES, GC_G1, GC_SERIAL, MultipleGcSelectedError
 
 
 class CheckJVMSettings:
@@ -75,3 +76,64 @@ class CheckJVMSettings:
                 res[param] = 1
 
         assert res == expected
+
+    def check_default_gc(self):
+        """
+        Without an explicit collector, create_jvm_settings yields the default 
profile and nothing from
+        any other one.
+        """
+        jvm_settings = create_jvm_settings()
+
+        for opt in GC_PROFILES[GC_G1]:
+            assert opt in jvm_settings
+
+        assert "-XX:+UseStringDeduplication" not in 
create_jvm_settings(gc_settings=GC_PROFILES[GC_SERIAL])
+
+    @pytest.mark.parametrize('gc_settings', [GC_PROFILES[GC_SERIAL], 
"-XX:+UseSerialGC"])
+    def check_gc_settings_accepts_list_and_string(self, gc_settings):
+        """
+        A stray caller passing a string keeps working.
+        """
+        assert "-XX:+UseSerialGC" in 
create_jvm_settings(gc_settings=gc_settings)
+
+    @pytest.mark.parametrize('gc_settings', [{"server": "ZGC"}, 42, 
("-XX:+UseSerialGC",)])
+    def check_gc_settings_rejects_other_types(self, gc_settings):
+        """
+        Anything else fails at the boundary: a dict would silently join to its 
keys.
+        """
+        with pytest.raises(AssertionError):
+            create_jvm_settings(gc_settings=gc_settings)
+
+    @pytest.mark.parametrize(
+        'jvm_opts',
+        [
+            ["-XX:+UseG1GC", "-XX:+UseZGC"],
+            ["-XX:+UseSerialGC", "-XX:+UseParallelGC", "-XX:+UseZGC"],
+            "-XX:+UseG1GC -XX:+UseShenandoahGC",
+        ]
+    )
+    def check_multiple_gc_selectors_raise(self, jvm_opts):
+        """
+        Two enabled collectors abort the JVM at startup; catch it in Python 
instead.
+        """
+        with pytest.raises(MultipleGcSelectedError):
+            validate_gc_settings(jvm_opts)
+
+        with pytest.raises(MultipleGcSelectedError):
+            merge_jvm_settings([], jvm_opts)
+
+    @pytest.mark.parametrize(
+        'jvm_opts',
+        [
+            ["-XX:+UseG1GC"],
+            ["-XX:+UseG1GC", "-XX:-UseZGC"],
+            ["-XX:+UseG1GC", "-XX:-UseG1GC", "-XX:+UseZGC"],  # last 
occurrence per collector wins
+            # neither of these is a collector selector, despite matching on a 
naive pattern
+            ["-XX:+UseG1GC", "-XX:+DisableExplicitGC", 
"-XX:+UseStringDeduplication"],
+        ]
+    )
+    def check_single_gc_selector_passes(self, jvm_opts):
+        """
+        One enabled collector, however it was arrived at, is fine.
+        """
+        assert validate_gc_settings(jvm_opts) == jvm_opts
diff --git a/modules/ducktests/tests/docker/requirements-dev.txt 
b/modules/ducktests/tests/docker/requirements-dev.txt
index 8ce0758663a..6f3ee95a485 100644
--- a/modules/ducktests/tests/docker/requirements-dev.txt
+++ b/modules/ducktests/tests/docker/requirements-dev.txt
@@ -16,4 +16,5 @@
 -r requirements.txt
 pytest==6.2.5
 flake8==6.1.0
-tox
+tox==4.25.0
+virtualenv==20.35.4
diff --git a/modules/ducktests/tests/docker/requirements.txt 
b/modules/ducktests/tests/docker/requirements.txt
index 1fc5cac411e..efb316f7dd5 100644
--- a/modules/ducktests/tests/docker/requirements.txt
+++ b/modules/ducktests/tests/docker/requirements.txt
@@ -13,7 +13,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-filelock==3.8.2
+filelock==3.16.1
 ducktape==0.15.0
 looseversion==1.3.0
 tcconfig==0.29.1
diff --git 
a/modules/ducktests/tests/ignitetest/services/utils/cdc/ignite_to_kafka_cdc_helper.py
 
b/modules/ducktests/tests/ignitetest/services/utils/cdc/ignite_to_kafka_cdc_helper.py
index 5aaf1314d8d..0f1834cdc3c 100644
--- 
a/modules/ducktests/tests/ignitetest/services/utils/cdc/ignite_to_kafka_cdc_helper.py
+++ 
b/modules/ducktests/tests/ignitetest/services/utils/cdc/ignite_to_kafka_cdc_helper.py
@@ -46,8 +46,11 @@ class IgniteToKafkaCdcHelper(CdcHelper):
             cdc_params.kafka,
             dst_cluster,
             cdc_params=cdc_params,
-            jvm_opts=dst_cluster.spec.jvm_opts,
-            merge_with_default=True,
+            # The delta the test applied to the destination cluster, not its 
resolved option list --
+            # dst_cluster is a server and kafka_to_ignite is a client, so 
copying the resolution would
+            # carry the server's collector into a client JVM.
+            jvm_opts=dst_cluster.spec.user_jvm_opts,
+            merge_with_default=dst_cluster.spec.merge_with_default,
             modules=dst_cluster.modules
         )
 
@@ -128,7 +131,7 @@ def get_ignite_to_kafka_spec(base, kafka_connection_string, 
service):
 
             return templates
 
-    return IgniteToKafkaSpec(service, service.spec.jvm_opts, 
merge_with_default=True)
+    return service.spec.rebuild_as(IgniteToKafkaSpec)
 
 
 class KafkaCdcParams(CdcParams):
diff --git 
a/modules/ducktests/tests/ignitetest/services/utils/cdc/kafka_to_ignite.py 
b/modules/ducktests/tests/ignitetest/services/utils/cdc/kafka_to_ignite.py
index e5cba2a0b83..9516476beb5 100644
--- a/modules/ducktests/tests/ignitetest/services/utils/cdc/kafka_to_ignite.py
+++ b/modules/ducktests/tests/ignitetest/services/utils/cdc/kafka_to_ignite.py
@@ -25,6 +25,7 @@ from ignitetest.services.utils import IgniteServiceType
 from ignitetest.services.utils.config_template import ConfigTemplate
 from ignitetest.services.utils.ignite_configuration import 
IgniteThinClientConfiguration
 from ignitetest.services.utils.ignite_spec import envs_to_exports
+from ignitetest.services.utils.jvm_utils import merge_jvm_settings
 from ignitetest.utils.bean import Bean
 
 
@@ -57,7 +58,8 @@ class KafkaToIgniteService(IgniteService):
         self.spec = get_kafka_to_ignite_spec(self.spec.__class__,
                                              kafka.connection_string(), self)
 
-        self.spec.jvm_opts += ["-Dlog4j.configurationFile=file:" + 
self.log_config_file]
+        self.spec.jvm_opts = merge_jvm_settings(
+            self.spec.jvm_opts, ["-Dlog4j.configurationFile=file:" + 
self.log_config_file])
 
         self.kafka = kafka
 
@@ -219,7 +221,7 @@ def get_kafka_to_ignite_spec(base, kafka_connection_string, 
service):
             else:
                 return self.service.script(cmd)
 
-    return KafkaToIgniteSpec(service, service.spec.jvm_opts)
+    return service.spec.rebuild_as(KafkaToIgniteSpec)
 
 
 class KafkaPropertiesTemplate(ConfigTemplate):
diff --git a/modules/ducktests/tests/ignitetest/services/utils/gc_params.py 
b/modules/ducktests/tests/ignitetest/services/utils/gc_params.py
new file mode 100644
index 00000000000..91e03927c84
--- /dev/null
+++ b/modules/ducktests/tests/ignitetest/services/utils/gc_params.py
@@ -0,0 +1,88 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License
+
+"""
+This module resolves the garbage collector to use from Globals.
+
+GC selection is mutually-exclusive group replacement: a collector and its 
tuning flags travel together
+(see GC_PROFILES in jvm_utils). It therefore has to be chosen *before* the 
default option list is
+assembled -- patching it afterwards via jvm_opts leaves two selectors in the 
command line, because
+merge_jvm_settings overwrites per option, not per group.
+
+This is the single resolution point for the 'gc' global. Keep it that way.
+"""
+
+from ignitetest.services.utils.jvm_utils import DEFAULT_GC, GC_PROFILES
+
+GC_KEY_NAME = "gc"
+
+SERVER_ROLE = "server"
+CLIENT_ROLE = "client"
+
+
+def is_gc_configured(_globals: dict):
+    """
+    :param _globals: Globals parameters
+    :return: True if the run explicitly selects a garbage collector.
+    """
+    return GC_KEY_NAME in (_globals or {})
+
+
+def resolve_gc_settings(_globals: dict, role: str):
+    """
+    Gets garbage collector options from Globals. Three shapes are accepted:
+
+    {"gc": "ZGC"}                                            -- both roles
+    {"gc": {"server": "ZGC"}}                                -- servers only, 
clients keep the default
+    {"gc": {"server": "ZGC", "client": "SERIAL"}}            -- per role
+    {"gc": {"server": ["-XX:+UseZGC", "-XX:SoftMaxHeapSize=2G"]}}   -- raw 
options, escape hatch
+
+    Profile names are case-insensitive. A missing role, or a missing 'gc' key, 
yields the DEFAULT_GC
+    profile. A list value is used verbatim and bypasses profile validation -- 
that is the point of it.
+
+    :param _globals: Globals parameters
+    :param role: SERVER_ROLE or CLIENT_ROLE
+    :return: list of JVM options selecting and tuning the collector
+    """
+    configured = (_globals or {}).get(GC_KEY_NAME)
+
+    if configured is None:
+        return _profile(DEFAULT_GC)
+
+    if isinstance(configured, dict):
+        configured = configured.get(role)
+
+        if configured is None:
+            return _profile(DEFAULT_GC)
+
+    if isinstance(configured, list):
+        return list(configured)
+
+    if isinstance(configured, str):
+        name = configured.upper()
+
+        if name not in GC_PROFILES:
+            raise ValueError(f"Unknown garbage collector profile 
'{configured}' for role '{role}'. "
+                             f"Valid profiles: {', 
'.join(sorted(GC_PROFILES))}. "
+                             f"A list of raw JVM options is also accepted.")
+
+        return _profile(name)
+
+    raise ValueError(f"Unexpected value for the '{GC_KEY_NAME}' global: 
{configured!r}. Expected a profile "
+                     f"name, a list of raw JVM options, or a mapping of role 
to either of those.")
+
+
+def _profile(name):
+    return list(GC_PROFILES[name])
diff --git a/modules/ducktests/tests/ignitetest/services/utils/ignite_aware.py 
b/modules/ducktests/tests/ignitetest/services/utils/ignite_aware.py
index 8bd758f339d..3e148217945 100644
--- a/modules/ducktests/tests/ignitetest/services/utils/ignite_aware.py
+++ b/modules/ducktests/tests/ignitetest/services/utils/ignite_aware.py
@@ -117,10 +117,21 @@ class IgniteAwareService(BackgroundThreadService, 
IgnitePathAware, JvmProcessMix
 
         super().start_node(node, **kwargs)
 
-        wait_until(lambda: self.alive(node), timeout_sec=10)
+        wait_until(lambda: self.alive(node), timeout_sec=10, err_msg=lambda: 
self.__jvm_startup_failure_msg(node))
 
         ignite_jmx_mixin(node, self)
 
+    def __jvm_startup_failure_msg(self, node):
+        """
+        A JVM that rejects an option (an unknown collector, a bad heap value, 
two collectors selected)
+        dies before it logs anything Ignite-shaped, so without the console 
tail this is a bare timeout.
+        """
+        console_log = os.path.join(self.log_dir, "console.log")
+
+        output = node.account.ssh_capture(f"tail -n 30 {console_log}", 
allow_fail=True)
+
+        return f"{self.who_am_i(node)}: JVM did not start within 10 seconds. 
Tail of {console_log}:\n{output}"
+
     def stop_async(self, force_stop=False, **kwargs):
         """
         Stop in async way.
diff --git a/modules/ducktests/tests/ignitetest/services/utils/ignite_spec.py 
b/modules/ducktests/tests/ignitetest/services/utils/ignite_spec.py
index 1edad489ee3..ae7036df48c 100644
--- a/modules/ducktests/tests/ignitetest/services/utils/ignite_spec.py
+++ b/modules/ducktests/tests/ignitetest/services/utils/ignite_spec.py
@@ -30,7 +30,8 @@ from itertools import chain
 from ignitetest.services.utils import IgniteServiceType
 from ignitetest.services.utils.config_template import 
IgniteClientConfigTemplate, IgniteServerConfigTemplate, \
     IgniteLoggerConfigTemplate, IgniteThinClientConfigTemplate, 
IgniteThinJdbcConfigTemplate
-from ignitetest.services.utils.jvm_utils import create_jvm_settings, 
merge_jvm_settings
+from ignitetest.services.utils.gc_params import is_gc_configured, 
resolve_gc_settings, CLIENT_ROLE, SERVER_ROLE
+from ignitetest.services.utils.jvm_utils import create_jvm_settings, 
merge_jvm_settings, validate_gc_settings
 from ignitetest.services.utils.path import get_home_dir, IgnitePathAware
 from ignitetest.services.utils.ssl.ssl_params import is_ssl_enabled
 from ignitetest.services.utils.metrics.metrics import 
is_opencensus_metrics_enabled, configure_opencensus_metrics, \
@@ -77,6 +78,24 @@ def envs_to_exports(envs):
     return "; ".join(exports) + ";"
 
 
+def service_role(service):
+    """
+    Determine the GC role of a service semantically rather than by spec class: 
IgniteService can run in
+    client mode, and subclasses of it (KafkaToIgniteService) are clients too, 
so an isinstance check
+    would hand those nodes server settings.
+
+    :param service: Service
+    :return: SERVER_ROLE or CLIENT_ROLE
+    """
+    config = service.config
+
+    # client_mode is absent on thin-client / thin-JDBC configs, so the service 
type check must come first.
+    if config.service_type == IgniteServiceType.NODE and not 
config.client_mode:
+        return SERVER_ROLE
+
+    return CLIENT_ROLE
+
+
 class IgniteSpec(metaclass=ABCMeta):
     """
     This class is a basic Spec
@@ -91,14 +110,51 @@ class IgniteSpec(metaclass=ABCMeta):
                          default options will be applied.
         """
         self.service = service
-        self.jvm_opts = merge_jvm_settings(self.__get_default_jvm_opts() if 
merge_with_default else [],
-                                           jvm_opts if jvm_opts else [])
+
+        # The caller's delta is kept alongside the merged result so that 
another service can inherit the
+        # tuning a test applied here without inheriting this service's 
role-dependent options too.
+        self.user_jvm_opts = jvm_opts.split() if isinstance(jvm_opts, str) 
else list(jvm_opts or [])
+        self.merge_with_default = merge_with_default
+
+        if merge_with_default:
+            defaults = merge_jvm_settings(self.__get_default_jvm_opts(), 
self._service_defaults())
+        else:
+            defaults = []
+
+            if is_gc_configured(self.service.context.globals):
+                self.service.logger.warning(
+                    f"{type(self.service).__name__} is built with 
merge_with_default=False, so the 'gc' global "
+                    f"does not apply to it. Its collector comes from jvm_opts 
only.")
+
+        self.jvm_opts = merge_jvm_settings(defaults, self.user_jvm_opts)
+
+    def rebuild_as(self, spec_class, **kwargs):
+        """
+        Re-create this spec as another class, reproducing the original 
resolution.
+
+        Passing the *resolved* self.jvm_opts instead would leak this service's 
role-dependent options
+        (the GC block) into the new spec.
+
+        :param spec_class: IgniteSpec subclass to build.
+        :return: instance of spec_class.
+        """
+        return spec_class(self.service, jvm_opts=self.user_jvm_opts,
+                          merge_with_default=self.merge_with_default, **kwargs)
+
+    def _service_defaults(self):
+        """
+        :return: spec-specific default JVM options, merged after the common 
defaults.
+        """
+        return []
 
     def __get_default_jvm_opts(self):
         """
         Return a set of default JVM options.
         """
-        default_jvm_opts = 
create_jvm_settings(gc_dump_path=os.path.join(self.service.log_dir, "gc.log"),
+        gc_settings = resolve_gc_settings(self.service.context.globals, 
service_role(self.service))
+
+        default_jvm_opts = create_jvm_settings(gc_settings=gc_settings,
+                                               
gc_dump_path=os.path.join(self.service.log_dir, "gc.log"),
                                                
oom_path=os.path.join(self.service.log_dir, "out_of_mem.hprof"),
                                                
vm_error_path=os.path.join(self.service.log_dir, "hs_err_pid%p.log"))
 
@@ -310,6 +366,10 @@ class IgniteSpec(metaclass=ABCMeta):
         """
         :return: line with extra JVM params for ignite.sh script: 
-J-Dparam=value -J-ea
         """
+        # Second validation pass: every code path building a command line goes 
through here, so this
+        # catches options appended to jvm_opts directly, bypassing 
merge_jvm_settings.
+        validate_gc_settings(self.jvm_opts)
+
         opts = ["-J%s" % o for o in self.jvm_opts]
         return " ".join(opts)
 
@@ -342,17 +402,12 @@ class IgniteApplicationSpec(IgniteSpec):
     """
     Spec to run ignite application
     """
-    def __init__(self, service, jvm_opts=None, merge_with_default=True):
-        super().__init__(
-            service,
-            merge_jvm_settings(self.__get_default_jvm_opts() if 
merge_with_default else [],
-                               jvm_opts if jvm_opts else []),
-            merge_with_default)
-
-    def __get_default_jvm_opts(self):
+    def _service_defaults(self):
         return [
             "-DIGNITE_NO_SHUTDOWN_HOOK=true",  # allows performing operations 
on app termination.
             "-Xmx1G",
+            "-Xms1G",
+            "-XX:+AlwaysPreTouch",
             "-ea",
             "-DIGNITE_ALLOW_ATOMIC_OPS_IN_TX=false"
         ]
diff --git a/modules/ducktests/tests/ignitetest/services/utils/jvm_utils.py 
b/modules/ducktests/tests/ignitetest/services/utils/jvm_utils.py
index b67c0a471bc..29b1ee831b0 100644
--- a/modules/ducktests/tests/ignitetest/services/utils/jvm_utils.py
+++ b/modules/ducktests/tests/ignitetest/services/utils/jvm_utils.py
@@ -17,25 +17,83 @@
 This module contains JVM utilities.
 """
 
+import re
+
 from ignitetest.services.utils.decorators import memoize
 
 DEFAULT_HEAP = "768M"
 
-JVM_PARAMS_GC_G1 = "-XX:+UseG1GC -XX:MaxGCPauseMillis=100 " \
-                   "-XX:ConcGCThreads=$(((`nproc`/3)>1?(`nproc`/3):1)) " \
-                   "-XX:ParallelGCThreads=$(((`nproc`*3/4)>1?(`nproc`*3/4):1)) 
"
+GC_G1 = "G1"
+GC_PARALLEL = "PARALLEL"
+GC_SERIAL = "SERIAL"
+GC_Z = "ZGC"
+GC_SHENANDOAH = "SHENANDOAH"
+
+DEFAULT_GC = GC_G1
+
+# NOTE: these strings are interpolated into a shell command that is evaluated 
on the remote
+# node (see IgniteSpec._jvm_opts and IgniteNodeSpec.command), which is what 
makes the `nproc`
+# substitutions work. Consequently NO option here may contain spaces or quotes.
+_NPROC_THIRD = "$(((`nproc`/3)>1?(`nproc`/3):1))"
+_NPROC_THREE_QUARTERS = "$(((`nproc`*3/4)>1?(`nproc`*3/4):1))"
+
+# Garbage collector profiles. A profile is a mutually exclusive group: it both 
selects the collector
+# and carries the tuning flags that are meaningful for it. Never mix flags 
across profiles.
+GC_PROFILES = {
+    GC_G1: [
+        "-XX:+UseG1GC",
+        "-XX:MaxGCPauseMillis=100",
+        f"-XX:ConcGCThreads={_NPROC_THIRD}",
+        f"-XX:ParallelGCThreads={_NPROC_THREE_QUARTERS}",
+        "-XX:+UseStringDeduplication",  # G1-only until JDK 18, hence part of 
the profile
+    ],
+    GC_PARALLEL: [
+        "-XX:+UseParallelGC",
+        f"-XX:ParallelGCThreads={_NPROC_THREE_QUARTERS}",
+        # deliberately NO MaxGCPauseMillis: it flips ParallelGC into adaptive 
pause-goal sizing
+    ],
+    GC_SERIAL: [
+        "-XX:+UseSerialGC",
+    ],
+    GC_Z: [
+        "-XX:+UseZGC",  # product feature since JDK 15, no unlock flag needed
+        f"-XX:ConcGCThreads={_NPROC_THIRD}",
+        f"-XX:ParallelGCThreads={_NPROC_THREE_QUARTERS}",
+    ],
+    GC_SHENANDOAH: [
+        "-XX:+UseShenandoahGC",  # product feature since JDK 15; OpenJDK only, 
not Oracle JDK
+        f"-XX:ConcGCThreads={_NPROC_THIRD}",
+        f"-XX:ParallelGCThreads={_NPROC_THREE_QUARTERS}",
+    ],
+}
 
 JVM_PARAMS_GENERIC = "-server -XX:+DisableExplicitGC -XX:+AlwaysPreTouch " \
                      "-XX:+ParallelRefProcEnabled -XX:+DoEscapeAnalysis " \
-                     "-XX:+OptimizeStringConcat -XX:+UseStringDeduplication"
+                     "-XX:+OptimizeStringConcat"
+
+# Matches a collector selector like -XX:+UseZGC. Deliberately narrow: it must 
not match
+# -XX:+DisableExplicitGC or -XX:+UseStringDeduplication.
+_GC_SELECTOR_PATTERN = re.compile(r"^-XX:([+-])(Use\w+GC)$")
 
 
-def create_jvm_settings(heap_size=DEFAULT_HEAP, gc_settings=JVM_PARAMS_GC_G1, 
generic_params=JVM_PARAMS_GENERIC,
+class MultipleGcSelectedError(Exception):
+    """
+    Raised when JVM options end up selecting more than one garbage collector.
+    """
+
+
+def create_jvm_settings(heap_size=DEFAULT_HEAP, gc_settings=None, 
generic_params=JVM_PARAMS_GENERIC,
                         gc_dump_path=None, oom_path=None, vm_error_path=None):
     """
     Provides settings string for JVM process.
-    param opts: JVM options to merge. Adds new or rewrites default values. Can 
be list or string.
+    :param heap_size: value for both -Xmx and -Xms.
+    :param gc_settings: garbage collector options, see GC_PROFILES. Can be 
list or string.
+                        Defaults to the DEFAULT_GC profile.
+    :param generic_params: collector-independent options. Can be list or 
string.
     """
+    gc_settings = _as_opts_list(GC_PROFILES[DEFAULT_GC] if gc_settings is None 
else gc_settings, "gc_settings")
+    generic_params = _as_opts_list(generic_params, "generic_params")
+
     gc_dump = ""
     if gc_dump_path:
         gc_dump = "-Xlog:gc*=debug,gc+stats*=debug,gc+ergo*=debug:" + 
gc_dump_path + ":uptime,time,level,tags"
@@ -48,8 +106,8 @@ def create_jvm_settings(heap_size=DEFAULT_HEAP, 
gc_settings=JVM_PARAMS_GC_G1, ge
     if vm_error_path:
         vm_error_dump = "-XX:ErrorFile=" + vm_error_path
 
-    as_string = f"-Xmx{heap_size} -Xms{heap_size} {gc_settings} {gc_dump} " \
-                f"{out_of_mem_dump} {vm_error_dump} {generic_params}".strip()
+    as_string = f"-Xmx{heap_size} -Xms{heap_size} {' '.join(gc_settings)} 
{gc_dump} " \
+                f"{out_of_mem_dump} {vm_error_dump} {' 
'.join(generic_params)}".strip()
 
     return as_string.split()
 
@@ -74,9 +132,45 @@ def merge_jvm_settings(src_settings, additionals):
         else:
             listed.append(param)
 
+    validate_gc_settings(listed)
+
     return listed
 
 
+def validate_gc_settings(jvm_opts):
+    """
+    Checks that at most one garbage collector is selected.
+
+    GC selection is a mutually exclusive group, but merge_jvm_settings is a 
per-option overwrite model
+    keyed on the substring before the first '=' -- so -XX:+UseG1GC and 
-XX:+UseZGC are different keys and
+    both survive a merge. The resulting JVM aborts at startup with "Multiple 
garbage collectors selected",
+    which surfaces on the Python side as an unexplained node startup timeout. 
Fail here instead.
+
+    :param jvm_opts: JVM options to check. Can be list or string.
+    :raise MultipleGcSelectedError: if more than one collector is enabled.
+    """
+    jvm_opts = _as_opts_list(jvm_opts, "JVM options")
+
+    # Last occurrence wins, matching how the JVM itself resolves repeated 
flags.
+    selectors = {}
+
+    for opt in jvm_opts:
+        match = _GC_SELECTOR_PATTERN.match(opt)
+        if match:
+            selectors[match.group(2)] = (match.group(1) == "+", opt)
+
+    enabled = sorted(opt for is_enabled, opt in selectors.values() if 
is_enabled)
+
+    if len(enabled) > 1:
+        raise MultipleGcSelectedError(
+            f"Multiple garbage collectors selected: {', '.join(enabled)}. "
+            f"Select a collector with the 'gc' global instead of passing it 
via jvm_opts, "
+            f"e.g. --global-json '{{\"gc\": \"ZGC\"}}'. "
+            f"Valid profiles: {', '.join(sorted(GC_PROFILES))}.")
+
+    return jvm_opts
+
+
 def java_major_version(version):
     """
     :param version: Full java version
@@ -102,12 +196,22 @@ def java_version(node):
     return raw_version[0].strip() if raw_version else ''
 
 
+def _as_opts_list(params, name="JVM params"):
+    """
+    Normalizes JVM options to a list: a string is split on whitespace, a list 
is copied.
+
+    :param params: options as a string or a list.
+    :param name: what to call them in the failure message.
+    :return: options as a list.
+    """
+    assert isinstance(params, (str, list)), f"{name} can be string or list 
only, got {type(params).__name__}."
+
+    return params.split() if isinstance(params, str) else list(params)
+
+
 def _to_map(params):
     """"""
-    assert isinstance(params, (str, list)), "JVM params an be string or list 
only."
-
-    if isinstance(params, str):
-        params = params.split()
+    params = _as_opts_list(params)
 
     mapped = {}
 
diff --git 
a/modules/ducktests/tests/ignitetest/services/utils/metrics/metrics.py 
b/modules/ducktests/tests/ignitetest/services/utils/metrics/metrics.py
index d40713f3609..cbb39666aea 100644
--- a/modules/ducktests/tests/ignitetest/services/utils/metrics/metrics.py
+++ b/modules/ducktests/tests/ignitetest/services/utils/metrics/metrics.py
@@ -15,6 +15,7 @@
 
 from typing import NamedTuple
 
+from ignitetest.services.utils.jvm_utils import merge_jvm_settings
 from ignitetest.utils.bean import Bean
 from ignitetest.utils.version import V_2_7_6
 
@@ -73,7 +74,8 @@ def configure_opencensus_metrics(config, _globals, spec):
                                      sendInstanceName=True))
 
     if not any("opencensus.metrics.port" in jvm_opt for jvm_opt in 
spec.jvm_opts):
-        spec.jvm_opts.append("-Dopencensus.metrics.port=%d" % 
metrics_params.port)
+        spec.jvm_opts = merge_jvm_settings(spec.jvm_opts,
+                                           ["-Dopencensus.metrics.port=%d" % 
metrics_params.port])
 
     if not any(bean[0] == OPENCENSUS_TEMPLATE_FILE for bean in 
config.ext_beans):
         config.ext_beans.append((OPENCENSUS_TEMPLATE_FILE, metrics_params))
diff --git a/modules/ducktests/tests/tox.ini b/modules/ducktests/tests/tox.ini
index c40e563eb26..efb728f84a4 100644
--- a/modules/ducktests/tests/tox.ini
+++ b/modules/ducktests/tests/tox.ini
@@ -14,7 +14,6 @@
 # limitations under the License.
 [tox]
 envlist = codestyle, py{38,39,310,311,312,313}
-skipsdist = True
 
 [testenv]
 usedevelop = True
@@ -31,6 +30,7 @@ commands = pytest {env:PYTESTARGS:} {posargs}
 
 [testenv:codestyle]
 basepython = python3
+skip_install = True
 deps = flake8
 commands = flake8
 

Reply via email to