anton-vinogradov commented on code in PR #13285:
URL: https://github.com/apache/ignite/pull/13285#discussion_r3494537477


##########
modules/ducktests/DEV_GUIDE.md:
##########
@@ -0,0 +1,541 @@
+<!--
+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.
+-->
+# How to Write a New DuckTest
+
+The ignite-ducktests framework is a bilingual integration testing framework:
+
+    
┌──────────────────────┬──────────┬───────────────────────────────────────────────────────────────────┐
+    │ Layer                │ Language │ Purpose                                
                           │
+    
├──────────────────────┼──────────┼───────────────────────────────────────────────────────────────────┤
+    │ Test orchestration   │ Python   │ Manages Docker containers, 
starts/stops nodes, asserts results    │
+    │ In-cluster workloads │ Java     │ Runs inside Ignite nodes (cache ops, 
transactions, queries, etc.) │
+    
└──────────────────────┴──────────┴───────────────────────────────────────────────────────────────────┘
+
+Each Ignite node runs in a separate Docker container. The Python layer manages 
container lifecycle and simulates network failures via iptables.
+
+---
+## Write a Java Application (if needed)
+
+All Java applications extend IgniteAwareApplication. Create a new file under:
+modules/ducktests/src/main/java/org/apache/ignite/internal/ducktest/tests/<your_package>/<YourApp>.java
+
+### Example:
+```java
+package org.apache.ignite.internal.ducktest.tests.mytest;
+      
+import com.fasterxml.jackson.databind.JsonNode;
+import org.apache.ignite.internal.ducktest.utils.IgniteAwareApplication;
+
+public class MyTestApp extends IgniteAwareApplication {
+    @Override
+    protected void run(JsonNode params) throws Exception {
+        // 1. Signal initialization — Python side waits for this
+        markInitialized();
+        // 2. Parse parameters passed from Python
+        String cacheName = params.get("cacheName").asText();
+        int range = params.get("range").asInt(1000);
+     
+        // 3. Use the cluster (depends on service type)
+        //    NODE mode       → this.ignite        (full Ignite node)
+        //    THIN_CLIENT     → this.client         (IgniteClient)
+        //    THIN_JDBC       → this.thinJdbcDataSource
+        IgniteCache<Integer, String> cache = ignite.cache(cacheName);
+        for (int i = 0; i < range; i++)
+            cache.put(i, "val-" + i);
+        // 4. Record a result back to Python (readable via extract_result())
+        recordResult("putCount", String.valueOf(range));
+        
+        // 5. For long-running apps, loop until SIGTERM:
+        //    while (!terminated()) { U.sleep(100); }
+     
+        // 6. Signal completion — Python side waits for this on stop()
+        markFinished();
+    }
+}
+```
+---
+## Application Lifecycle Methods
+    
┌─────────────────────────────┬─────────────────────────────────────┬───────────────────────────────────────────┐
+    │ Method                      │ What it does                        │ 
Python effect                             │
+    
├─────────────────────────────┼─────────────────────────────────────┼───────────────────────────────────────────┤
+    │ markInitialized()           │ Prints IGNITE_APPLICATION_INITIALIZED │ 
Required before start() returns
+    │
+    │ markFinished()              │ Prints IGNITE_APPLICATION_FINISHED    │ 
Required before stop() returns
+    │
+    │ markBroken(Throwable)       │ Prints IGNITE_APPLICATION_BROKEN      │ 
Raises IgniteExecutionException in Python
+    │
+    │ recordResult(name, value)   │ Prints name->value<-                │ Read 
via app.extract_result(name)         │
+    │ markSyncExecutionComplete() │ Run-to-completion shortcut          │ Use 
instead of init + finish pair         │
+    
└─────────────────────────────┴─────────────────────────────────────┴───────────────────────────────────────────┘
+
+---
+## Service Types (what connection the app gets)
+
+    
┌──────────────────┬───────────────────────────────┬─────────────────────────┐
+    │ Service Type     │ Python config class           │ Java field            
  │
+    
├──────────────────┼───────────────────────────────┼─────────────────────────┤
+    │ Full server node │ IgniteConfiguration           │ this.ignite           
  │
+    │ Thin client      │ IgniteThinClientConfiguration │ this.client           
  │
+    │ Thin JDBC        │ (same, mode=THIN_JDBC)        │ 
this.thinJdbcDataSource │
+    │ No connection    │ service_type = NONE           │ nothing               
  │
+    
└──────────────────┴───────────────────────────────┴─────────────────────────┘
+
+---
+## Write a Python Test
+
+Create a new file under:
+modules/ducktests/tests/ignitetest/tests/<your_test>.py
+```python
+from ignitetest.services.ignite import IgniteService
+from ignitetest.services.ignite_app import IgniteApplicationService
+from ignitetest.services.utils.ignite_configuration import (
+    IgniteConfiguration, IgniteThinClientConfiguration
+)
+from ignitetest.services.utils.ignite_configuration.cache import 
CacheConfiguration
+from ignitetest.services.utils.ignite_configuration.discovery import 
from_ignite_cluster
+from ignitetest.utils import ignite_versions, cluster
+from ignitetest.utils.ignite_test import IgniteTest
+from ignitetest.utils.version import DEV_BRANCH, LATEST, IgniteVersion
+
+class MyTest(IgniteTest):
+    @cluster(num_nodes=3)                          # max containers needed
+    @ignite_versions(str(DEV_BRANCH), str(LATEST))  # parameterize by version
+    def test_cache_operations(self, ignite_version):
+        # ── 1. Start server nodes ──────────────────────────────
+        server_config = IgniteConfiguration(
+            version=IgniteVersion(ignite_version),
+            caches=[CacheConfiguration(name='test-cache', backups=1)]
+        )
+        servers = IgniteService(self.test_context, server_config, num_nodes=2)
+        servers.start()
+        # ── 2. Start a client application ──────────────────────
+        client_config = IgniteThinClientConfiguration(
+            version=IgniteVersion(ignite_version),
+            discovery_spi=from_ignite_cluster(servers)

Review Comment:
   `IgniteThinClientConfiguration` is a standalone `NamedTuple` with only 
`addresses` and `version` — it has no `discovery_spi` field, so this raises 
`TypeError: __new__() got an unexpected keyword argument 'discovery_spi'`. Thin 
clients connect by address (cf. `thin_client_test.py`, `auth_test.py`). 
Suggested fix — set the connector on the server and pass `addresses`:
   
   ```python
   server_config = IgniteConfiguration(
       version=IgniteVersion(ignite_version),
       client_connector_configuration=ClientConnectorConfiguration(),
       caches=[CacheConfiguration(name='test-cache', backups=1)]
   )
   ...
   addresses = [servers.nodes[0].account.hostname + ":" +
                str(server_config.client_connector_configuration.port)]
   client_config = IgniteThinClientConfiguration(
       version=IgniteVersion(ignite_version),
       addresses=addresses
   )
   ```



##########
modules/ducktests/DEV_GUIDE.md:
##########
@@ -0,0 +1,541 @@
+<!--
+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.
+-->
+# How to Write a New DuckTest
+
+The ignite-ducktests framework is a bilingual integration testing framework:
+
+    
┌──────────────────────┬──────────┬───────────────────────────────────────────────────────────────────┐
+    │ Layer                │ Language │ Purpose                                
                           │
+    
├──────────────────────┼──────────┼───────────────────────────────────────────────────────────────────┤
+    │ Test orchestration   │ Python   │ Manages Docker containers, 
starts/stops nodes, asserts results    │
+    │ In-cluster workloads │ Java     │ Runs inside Ignite nodes (cache ops, 
transactions, queries, etc.) │
+    
└──────────────────────┴──────────┴───────────────────────────────────────────────────────────────────┘
+
+Each Ignite node runs in a separate Docker container. The Python layer manages 
container lifecycle and simulates network failures via iptables.
+
+---
+## Write a Java Application (if needed)
+
+All Java applications extend IgniteAwareApplication. Create a new file under:
+modules/ducktests/src/main/java/org/apache/ignite/internal/ducktest/tests/<your_package>/<YourApp>.java
+
+### Example:
+```java
+package org.apache.ignite.internal.ducktest.tests.mytest;
+      
+import com.fasterxml.jackson.databind.JsonNode;
+import org.apache.ignite.internal.ducktest.utils.IgniteAwareApplication;
+
+public class MyTestApp extends IgniteAwareApplication {
+    @Override
+    protected void run(JsonNode params) throws Exception {
+        // 1. Signal initialization — Python side waits for this
+        markInitialized();
+        // 2. Parse parameters passed from Python
+        String cacheName = params.get("cacheName").asText();
+        int range = params.get("range").asInt(1000);
+     
+        // 3. Use the cluster (depends on service type)
+        //    NODE mode       → this.ignite        (full Ignite node)
+        //    THIN_CLIENT     → this.client         (IgniteClient)
+        //    THIN_JDBC       → this.thinJdbcDataSource
+        IgniteCache<Integer, String> cache = ignite.cache(cacheName);
+        for (int i = 0; i < range; i++)
+            cache.put(i, "val-" + i);
+        // 4. Record a result back to Python (readable via extract_result())
+        recordResult("putCount", String.valueOf(range));
+        
+        // 5. For long-running apps, loop until SIGTERM:
+        //    while (!terminated()) { U.sleep(100); }
+     
+        // 6. Signal completion — Python side waits for this on stop()
+        markFinished();
+    }
+}
+```
+---
+## Application Lifecycle Methods
+    
┌─────────────────────────────┬─────────────────────────────────────┬───────────────────────────────────────────┐
+    │ Method                      │ What it does                        │ 
Python effect                             │
+    
├─────────────────────────────┼─────────────────────────────────────┼───────────────────────────────────────────┤
+    │ markInitialized()           │ Prints IGNITE_APPLICATION_INITIALIZED │ 
Required before start() returns
+    │
+    │ markFinished()              │ Prints IGNITE_APPLICATION_FINISHED    │ 
Required before stop() returns
+    │
+    │ markBroken(Throwable)       │ Prints IGNITE_APPLICATION_BROKEN      │ 
Raises IgniteExecutionException in Python
+    │
+    │ recordResult(name, value)   │ Prints name->value<-                │ Read 
via app.extract_result(name)         │
+    │ markSyncExecutionComplete() │ Run-to-completion shortcut          │ Use 
instead of init + finish pair         │
+    
└─────────────────────────────┴─────────────────────────────────────┴───────────────────────────────────────────┘
+
+---
+## Service Types (what connection the app gets)
+
+    
┌──────────────────┬───────────────────────────────┬─────────────────────────┐
+    │ Service Type     │ Python config class           │ Java field            
  │
+    
├──────────────────┼───────────────────────────────┼─────────────────────────┤
+    │ Full server node │ IgniteConfiguration           │ this.ignite           
  │
+    │ Thin client      │ IgniteThinClientConfiguration │ this.client           
  │
+    │ Thin JDBC        │ (same, mode=THIN_JDBC)        │ 
this.thinJdbcDataSource │
+    │ No connection    │ service_type = NONE           │ nothing               
  │
+    
└──────────────────┴───────────────────────────────┴─────────────────────────┘
+
+---
+## Write a Python Test
+
+Create a new file under:
+modules/ducktests/tests/ignitetest/tests/<your_test>.py
+```python
+from ignitetest.services.ignite import IgniteService
+from ignitetest.services.ignite_app import IgniteApplicationService
+from ignitetest.services.utils.ignite_configuration import (
+    IgniteConfiguration, IgniteThinClientConfiguration
+)
+from ignitetest.services.utils.ignite_configuration.cache import 
CacheConfiguration
+from ignitetest.services.utils.ignite_configuration.discovery import 
from_ignite_cluster
+from ignitetest.utils import ignite_versions, cluster
+from ignitetest.utils.ignite_test import IgniteTest
+from ignitetest.utils.version import DEV_BRANCH, LATEST, IgniteVersion
+
+class MyTest(IgniteTest):
+    @cluster(num_nodes=3)                          # max containers needed
+    @ignite_versions(str(DEV_BRANCH), str(LATEST))  # parameterize by version
+    def test_cache_operations(self, ignite_version):
+        # ── 1. Start server nodes ──────────────────────────────
+        server_config = IgniteConfiguration(
+            version=IgniteVersion(ignite_version),
+            caches=[CacheConfiguration(name='test-cache', backups=1)]
+        )
+        servers = IgniteService(self.test_context, server_config, num_nodes=2)
+        servers.start()
+        # ── 2. Start a client application ──────────────────────
+        client_config = IgniteThinClientConfiguration(
+            version=IgniteVersion(ignite_version),
+            discovery_spi=from_ignite_cluster(servers)
+        )
+        app = IgniteApplicationService(
+            self.test_context,
+            client_config,
+            
java_class_name="org.apache.ignite.internal.ducktest.tests.mytest.MyTestApp",
+            params={"cacheName": "test-cache", "range": 1000}
+        )
+        app.start()  # blocks until IGNITE_APPLICATION_INITIALIZED
+        # ── 3. Verify results ──────────────────────────────────
+        result = app.extract_result("putCount")

Review Comment:
   `app.start()` only waits for `IGNITE_APPLICATION_INITIALIZED`. Since the 
Java sample calls `markInitialized()` *before* the `put` loop and 
`recordResult(...)`, the `putCount->...` line may not be in the log yet — so 
`extract_result` greps zero lines and trips `assert len(results) == 
len(self.nodes)`. Read the result after the app has finished, i.e. after 
`app.stop()` (which waits for `IGNITE_APPLICATION_FINISHED`); cf. 
`pme_free_switch_test.py`:
   
   ```python
   app.start()   # blocks until IGNITE_APPLICATION_INITIALIZED
   app.stop()    # blocks until IGNITE_APPLICATION_FINISHED
   result = app.extract_result("putCount")
   assert result == "1000", f"Expected 1000, got {result}"
   ```



##########
modules/ducktests/DEV_GUIDE.md:
##########
@@ -0,0 +1,541 @@
+<!--
+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.
+-->
+# How to Write a New DuckTest
+
+The ignite-ducktests framework is a bilingual integration testing framework:
+
+    
┌──────────────────────┬──────────┬───────────────────────────────────────────────────────────────────┐
+    │ Layer                │ Language │ Purpose                                
                           │
+    
├──────────────────────┼──────────┼───────────────────────────────────────────────────────────────────┤
+    │ Test orchestration   │ Python   │ Manages Docker containers, 
starts/stops nodes, asserts results    │
+    │ In-cluster workloads │ Java     │ Runs inside Ignite nodes (cache ops, 
transactions, queries, etc.) │
+    
└──────────────────────┴──────────┴───────────────────────────────────────────────────────────────────┘
+
+Each Ignite node runs in a separate Docker container. The Python layer manages 
container lifecycle and simulates network failures via iptables.
+
+---
+## Write a Java Application (if needed)
+
+All Java applications extend IgniteAwareApplication. Create a new file under:
+modules/ducktests/src/main/java/org/apache/ignite/internal/ducktest/tests/<your_package>/<YourApp>.java
+
+### Example:
+```java
+package org.apache.ignite.internal.ducktest.tests.mytest;
+      
+import com.fasterxml.jackson.databind.JsonNode;
+import org.apache.ignite.internal.ducktest.utils.IgniteAwareApplication;
+
+public class MyTestApp extends IgniteAwareApplication {
+    @Override
+    protected void run(JsonNode params) throws Exception {
+        // 1. Signal initialization — Python side waits for this
+        markInitialized();
+        // 2. Parse parameters passed from Python
+        String cacheName = params.get("cacheName").asText();
+        int range = params.get("range").asInt(1000);
+     
+        // 3. Use the cluster (depends on service type)
+        //    NODE mode       → this.ignite        (full Ignite node)
+        //    THIN_CLIENT     → this.client         (IgniteClient)
+        //    THIN_JDBC       → this.thinJdbcDataSource
+        IgniteCache<Integer, String> cache = ignite.cache(cacheName);
+        for (int i = 0; i < range; i++)
+            cache.put(i, "val-" + i);
+        // 4. Record a result back to Python (readable via extract_result())
+        recordResult("putCount", String.valueOf(range));
+        
+        // 5. For long-running apps, loop until SIGTERM:
+        //    while (!terminated()) { U.sleep(100); }
+     
+        // 6. Signal completion — Python side waits for this on stop()
+        markFinished();
+    }
+}
+```
+---
+## Application Lifecycle Methods
+    
┌─────────────────────────────┬─────────────────────────────────────┬───────────────────────────────────────────┐
+    │ Method                      │ What it does                        │ 
Python effect                             │
+    
├─────────────────────────────┼─────────────────────────────────────┼───────────────────────────────────────────┤
+    │ markInitialized()           │ Prints IGNITE_APPLICATION_INITIALIZED │ 
Required before start() returns
+    │
+    │ markFinished()              │ Prints IGNITE_APPLICATION_FINISHED    │ 
Required before stop() returns
+    │
+    │ markBroken(Throwable)       │ Prints IGNITE_APPLICATION_BROKEN      │ 
Raises IgniteExecutionException in Python
+    │
+    │ recordResult(name, value)   │ Prints name->value<-                │ Read 
via app.extract_result(name)         │
+    │ markSyncExecutionComplete() │ Run-to-completion shortcut          │ Use 
instead of init + finish pair         │
+    
└─────────────────────────────┴─────────────────────────────────────┴───────────────────────────────────────────┘
+
+---
+## Service Types (what connection the app gets)
+
+    
┌──────────────────┬───────────────────────────────┬─────────────────────────┐
+    │ Service Type     │ Python config class           │ Java field            
  │
+    
├──────────────────┼───────────────────────────────┼─────────────────────────┤
+    │ Full server node │ IgniteConfiguration           │ this.ignite           
  │
+    │ Thin client      │ IgniteThinClientConfiguration │ this.client           
  │
+    │ Thin JDBC        │ (same, mode=THIN_JDBC)        │ 
this.thinJdbcDataSource │
+    │ No connection    │ service_type = NONE           │ nothing               
  │
+    
└──────────────────┴───────────────────────────────┴─────────────────────────┘
+
+---
+## Write a Python Test
+
+Create a new file under:
+modules/ducktests/tests/ignitetest/tests/<your_test>.py
+```python
+from ignitetest.services.ignite import IgniteService
+from ignitetest.services.ignite_app import IgniteApplicationService
+from ignitetest.services.utils.ignite_configuration import (
+    IgniteConfiguration, IgniteThinClientConfiguration
+)
+from ignitetest.services.utils.ignite_configuration.cache import 
CacheConfiguration
+from ignitetest.services.utils.ignite_configuration.discovery import 
from_ignite_cluster
+from ignitetest.utils import ignite_versions, cluster
+from ignitetest.utils.ignite_test import IgniteTest
+from ignitetest.utils.version import DEV_BRANCH, LATEST, IgniteVersion
+
+class MyTest(IgniteTest):
+    @cluster(num_nodes=3)                          # max containers needed
+    @ignite_versions(str(DEV_BRANCH), str(LATEST))  # parameterize by version
+    def test_cache_operations(self, ignite_version):
+        # ── 1. Start server nodes ──────────────────────────────
+        server_config = IgniteConfiguration(
+            version=IgniteVersion(ignite_version),
+            caches=[CacheConfiguration(name='test-cache', backups=1)]
+        )
+        servers = IgniteService(self.test_context, server_config, num_nodes=2)
+        servers.start()
+        # ── 2. Start a client application ──────────────────────
+        client_config = IgniteThinClientConfiguration(
+            version=IgniteVersion(ignite_version),
+            discovery_spi=from_ignite_cluster(servers)
+        )
+        app = IgniteApplicationService(
+            self.test_context,
+            client_config,
+            
java_class_name="org.apache.ignite.internal.ducktest.tests.mytest.MyTestApp",
+            params={"cacheName": "test-cache", "range": 1000}
+        )
+        app.start()  # blocks until IGNITE_APPLICATION_INITIALIZED
+        # ── 3. Verify results ──────────────────────────────────
+        result = app.extract_result("putCount")
+        assert result == "1000", f"Expected 1000, got {result}"
+        # ── 4. Teardown ────────────────────────────────────────
+        app.stop()
+        servers.stop()
+```
+---
+## Decorators Reference
+
+### @cluster Annotation
+@cluster is a mandatory test method decorator. It tells the ducktape framework 
how many Docker containers the test will consume and how exactly.
+
+#### Basic Usage
+```python
+from ignitetest.utils import cluster
+class MyTest(IgniteTest):
+    @cluster(num_nodes=3)
+    # Here num_nodes=3 means the test will consume up to 3
+    # containers from the available pool (default 13, set via -n in
+    # run_tests.sh).
+    @ignite_versions(str(DEV_BRANCH), str(LATEST))
+    def test_something(self, ignite_version):
+        servers = IgniteService(self.test_context, config, num_nodes=2)
+        servers.start()
+        # ... test logic ...
+        servers.stop()
+```
+#### Key Points:
+
+1. The decorator wraps the test in before()/after()
+   This means self.test_context is guaranteed to be initialized before the 
test runs.
+
+2. num_nodes is the peak requirement
+   Specify the maximum number of containers the test may consume 
simultaneously. For example, if the test launches 2
+   server nodes + 1 client app:
+```python 
+@cluster(num_nodes=3)  # 2 servers + 1 client = 3
+def test_with_client(self, ignite_version):
+    servers = IgniteService(self.test_context, config, num_nodes=2)
+    client = IgniteService(self.test_context, client_config, num_nodes=1)
+    servers.start()
+    client.start()
+```
+3. Global cluster_size parameter. You can override num_nodes at runtime via a 
global parameter:
+   ./docker/run_tests.sh -g cluster_size=5 -t ./ignitetest/tests/my_test.py
+4. Instead of num_nodes, you can specify a ClusterSpec from ducktape for typed 
node specifications:
+5. One @cluster per method. The decorator must appear once per test method. It 
sets cluster metadata in ctx.cluster_use_metadata. If the context already has 
metadata, it is not overwritten.
+
+#### Errors and Limitations
+
+    
┌────────────────────────────────────────┬───────────────────────────────────────────────────────────────┐
+    │ Situation                              │ Result                          
                              │
+    
├────────────────────────────────────────┼───────────────────────────────────────────────────────────────┤
+    │ num_nodes exceeds available containers │ Test will not start (ducktape 
reports insufficient resources) │
+    │ num_nodes <= 0                         │ Parsing error when reading 
global cluster_size                │
+    │ Multiple @cluster on one method        │ Only the top one applies        
                              │

Review Comment:
   This row contradicts key point #5 above ("if the context already has 
metadata, it is not overwritten"). Since `cluster()` wraps the function and 
decorators apply bottom-up, the inner (bottom) `@cluster` sets the metadata 
first and wins — and stacking also runs `before()/after()` twice. Suggest 
dropping this row or rewording to "the inner (bottom) `@cluster` applies".



##########
modules/ducktests/DEV_GUIDE.md:
##########
@@ -0,0 +1,541 @@
+<!--
+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.
+-->
+# How to Write a New DuckTest
+
+The ignite-ducktests framework is a bilingual integration testing framework:
+
+    
┌──────────────────────┬──────────┬───────────────────────────────────────────────────────────────────┐
+    │ Layer                │ Language │ Purpose                                
                           │
+    
├──────────────────────┼──────────┼───────────────────────────────────────────────────────────────────┤
+    │ Test orchestration   │ Python   │ Manages Docker containers, 
starts/stops nodes, asserts results    │
+    │ In-cluster workloads │ Java     │ Runs inside Ignite nodes (cache ops, 
transactions, queries, etc.) │
+    
└──────────────────────┴──────────┴───────────────────────────────────────────────────────────────────┘
+
+Each Ignite node runs in a separate Docker container. The Python layer manages 
container lifecycle and simulates network failures via iptables.
+
+---
+## Write a Java Application (if needed)
+
+All Java applications extend IgniteAwareApplication. Create a new file under:
+modules/ducktests/src/main/java/org/apache/ignite/internal/ducktest/tests/<your_package>/<YourApp>.java
+
+### Example:
+```java
+package org.apache.ignite.internal.ducktest.tests.mytest;
+      
+import com.fasterxml.jackson.databind.JsonNode;
+import org.apache.ignite.internal.ducktest.utils.IgniteAwareApplication;
+
+public class MyTestApp extends IgniteAwareApplication {
+    @Override
+    protected void run(JsonNode params) throws Exception {
+        // 1. Signal initialization — Python side waits for this
+        markInitialized();
+        // 2. Parse parameters passed from Python
+        String cacheName = params.get("cacheName").asText();
+        int range = params.get("range").asInt(1000);
+     
+        // 3. Use the cluster (depends on service type)
+        //    NODE mode       → this.ignite        (full Ignite node)
+        //    THIN_CLIENT     → this.client         (IgniteClient)
+        //    THIN_JDBC       → this.thinJdbcDataSource
+        IgniteCache<Integer, String> cache = ignite.cache(cacheName);
+        for (int i = 0; i < range; i++)
+            cache.put(i, "val-" + i);
+        // 4. Record a result back to Python (readable via extract_result())
+        recordResult("putCount", String.valueOf(range));
+        
+        // 5. For long-running apps, loop until SIGTERM:
+        //    while (!terminated()) { U.sleep(100); }
+     
+        // 6. Signal completion — Python side waits for this on stop()
+        markFinished();
+    }
+}
+```
+---
+## Application Lifecycle Methods
+    
┌─────────────────────────────┬─────────────────────────────────────┬───────────────────────────────────────────┐
+    │ Method                      │ What it does                        │ 
Python effect                             │
+    
├─────────────────────────────┼─────────────────────────────────────┼───────────────────────────────────────────┤
+    │ markInitialized()           │ Prints IGNITE_APPLICATION_INITIALIZED │ 
Required before start() returns
+    │
+    │ markFinished()              │ Prints IGNITE_APPLICATION_FINISHED    │ 
Required before stop() returns
+    │
+    │ markBroken(Throwable)       │ Prints IGNITE_APPLICATION_BROKEN      │ 
Raises IgniteExecutionException in Python
+    │
+    │ recordResult(name, value)   │ Prints name->value<-                │ Read 
via app.extract_result(name)         │
+    │ markSyncExecutionComplete() │ Run-to-completion shortcut          │ Use 
instead of init + finish pair         │
+    
└─────────────────────────────┴─────────────────────────────────────┴───────────────────────────────────────────┘
+
+---
+## Service Types (what connection the app gets)
+
+    
┌──────────────────┬───────────────────────────────┬─────────────────────────┐
+    │ Service Type     │ Python config class           │ Java field            
  │
+    
├──────────────────┼───────────────────────────────┼─────────────────────────┤
+    │ Full server node │ IgniteConfiguration           │ this.ignite           
  │
+    │ Thin client      │ IgniteThinClientConfiguration │ this.client           
  │
+    │ Thin JDBC        │ (same, mode=THIN_JDBC)        │ 
this.thinJdbcDataSource │
+    │ No connection    │ service_type = NONE           │ nothing               
  │
+    
└──────────────────┴───────────────────────────────┴─────────────────────────┘
+
+---
+## Write a Python Test
+
+Create a new file under:
+modules/ducktests/tests/ignitetest/tests/<your_test>.py
+```python
+from ignitetest.services.ignite import IgniteService
+from ignitetest.services.ignite_app import IgniteApplicationService
+from ignitetest.services.utils.ignite_configuration import (
+    IgniteConfiguration, IgniteThinClientConfiguration
+)
+from ignitetest.services.utils.ignite_configuration.cache import 
CacheConfiguration
+from ignitetest.services.utils.ignite_configuration.discovery import 
from_ignite_cluster
+from ignitetest.utils import ignite_versions, cluster
+from ignitetest.utils.ignite_test import IgniteTest
+from ignitetest.utils.version import DEV_BRANCH, LATEST, IgniteVersion
+
+class MyTest(IgniteTest):
+    @cluster(num_nodes=3)                          # max containers needed
+    @ignite_versions(str(DEV_BRANCH), str(LATEST))  # parameterize by version
+    def test_cache_operations(self, ignite_version):
+        # ── 1. Start server nodes ──────────────────────────────
+        server_config = IgniteConfiguration(
+            version=IgniteVersion(ignite_version),
+            caches=[CacheConfiguration(name='test-cache', backups=1)]
+        )
+        servers = IgniteService(self.test_context, server_config, num_nodes=2)
+        servers.start()
+        # ── 2. Start a client application ──────────────────────
+        client_config = IgniteThinClientConfiguration(
+            version=IgniteVersion(ignite_version),
+            discovery_spi=from_ignite_cluster(servers)
+        )
+        app = IgniteApplicationService(
+            self.test_context,
+            client_config,
+            
java_class_name="org.apache.ignite.internal.ducktest.tests.mytest.MyTestApp",
+            params={"cacheName": "test-cache", "range": 1000}
+        )
+        app.start()  # blocks until IGNITE_APPLICATION_INITIALIZED
+        # ── 3. Verify results ──────────────────────────────────
+        result = app.extract_result("putCount")
+        assert result == "1000", f"Expected 1000, got {result}"
+        # ── 4. Teardown ────────────────────────────────────────
+        app.stop()
+        servers.stop()
+```
+---
+## Decorators Reference
+
+### @cluster Annotation
+@cluster is a mandatory test method decorator. It tells the ducktape framework 
how many Docker containers the test will consume and how exactly.
+
+#### Basic Usage
+```python
+from ignitetest.utils import cluster
+class MyTest(IgniteTest):
+    @cluster(num_nodes=3)
+    # Here num_nodes=3 means the test will consume up to 3
+    # containers from the available pool (default 13, set via -n in
+    # run_tests.sh).
+    @ignite_versions(str(DEV_BRANCH), str(LATEST))
+    def test_something(self, ignite_version):
+        servers = IgniteService(self.test_context, config, num_nodes=2)
+        servers.start()
+        # ... test logic ...
+        servers.stop()
+```
+#### Key Points:
+
+1. The decorator wraps the test in before()/after()
+   This means self.test_context is guaranteed to be initialized before the 
test runs.
+
+2. num_nodes is the peak requirement
+   Specify the maximum number of containers the test may consume 
simultaneously. For example, if the test launches 2
+   server nodes + 1 client app:
+```python 
+@cluster(num_nodes=3)  # 2 servers + 1 client = 3
+def test_with_client(self, ignite_version):
+    servers = IgniteService(self.test_context, config, num_nodes=2)
+    client = IgniteService(self.test_context, client_config, num_nodes=1)
+    servers.start()
+    client.start()
+```
+3. Global cluster_size parameter. You can override num_nodes at runtime via a 
global parameter:
+   ./docker/run_tests.sh -g cluster_size=5 -t ./ignitetest/tests/my_test.py
+4. Instead of num_nodes, you can specify a ClusterSpec from ducktape for typed 
node specifications:
+5. One @cluster per method. The decorator must appear once per test method. It 
sets cluster metadata in ctx.cluster_use_metadata. If the context already has 
metadata, it is not overwritten.
+
+#### Errors and Limitations
+
+    
┌────────────────────────────────────────┬───────────────────────────────────────────────────────────────┐
+    │ Situation                              │ Result                          
                              │
+    
├────────────────────────────────────────┼───────────────────────────────────────────────────────────────┤
+    │ num_nodes exceeds available containers │ Test will not start (ducktape 
reports insufficient resources) │
+    │ num_nodes <= 0                         │ Parsing error when reading 
global cluster_size                │
+    │ Multiple @cluster on one method        │ Only the top one applies        
                              │
+    │ Missing @cluster                       │ before()/after() not executed — 
context is not initialized    │
+    
└────────────────────────────────────────┴───────────────────────────────────────────────────────────────┘
+
+---
+### @ignite_versions Annotation
+
+Purpose: @ignite_versions is a test method decorator that parameterizes tests 
across multiple Ignite versions. It automatically generates separate test 
executions for each specified version, injecting the version string into the 
test method as an argument.
+
+#### Basic Usage
+``` python
+from ignitetest.utils import ignite_versions, cluster
+from ignitetest.utils.version import DEV_BRANCH, LATEST
+class MyTest(IgniteTest):
+    @cluster(num_nodes=2)
+    @ignite_versions(str(DEV_BRANCH), str(LATEST))
+    def test_cache_ops(self, ignite_version):
+        # Runs twice: once for "dev", once for "2.17.0" (or whatever LATEST is)
+        config = IgniteConfiguration(version=IgniteVersion(ignite_version))
+        servers = IgniteService(self.test_context, config, num_nodes=1)
+        servers.start()
+        # ... test logic ...
+        servers.stop()
+```
+#### Key Points
+1. The decorator injects the version string into the test method using the 
version_prefix. By default, the prefix is ignite_version, so the method 
receives ignite_version="ignite-2.17.0".
+2. Versions specified in the decorator can be completely overridden at runtime 
using the ignite_versions global
+   parameter:
+   `./docker/run_tests.sh -gj '{"ignite_versions": ["2.15.0", "dev"]}' -t 
./ignitetest/tests/my_test.py`
+   If ignite_versions is present in globals, the decorator ignores *args and 
uses the global list instead. This
+   allows CI/CD pipelines to control test versions without code changes.
+
+3. You can stack multiple @ignite_versions decorators with different prefixes 
to test cross-version compatibility
+   (e.g., server vs. client versions):
+``` python
+@cluster(num_nodes=2)
+@ignite_versions(str(DEV_BRANCH), str(LATEST), version_prefix="server_version")
+@ignite_versions(str(DEV_BRANCH), str(LATEST), version_prefix="client_version")
+def test_cross_version(self, server_version, client_version):
+   # Generates a cartesian product of server/client versions
+```
+4. If a single test needs to spin up nodes with different versions 
simultaneously, pass a tuple/list of size ≥ 2 as
+   one argument: `@ignite_versions(("dev", "2.17.0"))`
+5. @ignite_versions must be placed below @cluster and above @matrix (or the 
method definition):
+
+#### Errors and Limitations
+
+     
┌────────────────────────────────────────────────────┬──────────────────────────────────────────────────────────┐
+     │ Situation                                          │ Result             
                                      │
+     
├────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤
+     │ Duplicate version_prefix on stacked decorators     │ Second decorator 
is skipped                                                                │
+     │ Global ignite_versions is not a string or iterable │ AssertionError 
during test collection                    │
+     │ Version string doesn't match to installed          │ Test fails at 
runtime                                    │
+     │ Mixing single-string and tuple                     │ Unpredictable 
argument names; use distinct prefixes      │
+     
└────────────────────────────────────────────────────┴──────────────────────────────────────────────────────────┘
+#### Examples:
+```python
+#Minimal (Single Version Parameter)
+@cluster(num_nodes=1)
+@ignite_versions(str(DEV_BRANCH))
+def test_basic(self, ignite_version):
+   config = IgniteConfiguration(version=IgniteVersion(ignite_version))
+   # ...
+```
+``` python
+#Cross-Version Compatibility
+@cluster(num_nodes=3)
+@ignite_versions(str(DEV_BRANCH), str(LATEST), version_prefix="server_ver")
+@ignite_versions(str(DEV_BRANCH), str(LATEST), version_prefix="client_ver")
+```
+``` python
+   Mixed-Version Cluster
+@cluster(num_nodes=2)
+@ignite_versions(("dev", "2.17.0"))
+```
+---
+### @matrix Annotation
+
+Purpose: @matrix is a standard ducktape decorator that parameterizes test 
methods by creating a cartesian product of all provided parameters. Each unique 
combination of parameters results in a separate test execution.
+
+#### Basic Usage
+``` python
+from ducktape.tests.test import matrix
+from ignitetest.utils import cluster, ignite_versions
+class MyTest(IgniteTest):
+   @cluster(num_nodes=4)
+   @ignite_versions(str(DEV_BRANCH), str(LATEST))
+   @matrix(nodes_to_kill=[1, 2], load_type=["atomic", "transactional"])
+   def test_failure(self, ignite_version, nodes_to_kill, load_type):
+       # Generates 2 versions × 2 nodes_to_kill × 2 load_type = 8 test 
executions
+```
+#### Key Points
+1.The decorator creates all possible combinations of the provided parameters. 
For example:
+@matrix(a=[1, 2], b=["x", "y"])
+Produces 4 test cases:
+- a=1, b="x"
+- a=1, b="y"
+- a=2, b="x"
+- a=2, b="y"
+2. Each parameter from @matrix is injected as a keyword argument into the test 
method. The method signature must include all injected parameters:
+``` python
+@matrix(nodes_to_kill=[1, 2], net_partition=True)
+def test_something(self, ignite_version, nodes_to_kill, net_partition):
+   # nodes_to_kill and net_partition are injected by @matrix
+   # ignite_version is injected by @ignite_versions
+```
+3. @matrix and @ignite_versions work together. The total number of test 
executions is:
+   `(versions count) × (matrix combinations count)`
+4. Any Python object can be used as a parameter value:
+```python
+@matrix(
+num_backups=[0, 1, 2],
+cache_mode=[CacheMode.PARTITIONED, CacheMode.REPLICATED],
+enabled=[True, False],
+timeout_ms=[5000, 10000]
+)
+def test_config(self, num_backups, cache_mode, enabled, timeout_ms):
+```
+5. If any parameter list is empty, the entire parameter combination is skipped 
— no test cases are generated for that decorator.
+
+6. @matrix must be placed below @cluster and @ignite_versions, and above the 
method definition:
+
+#### Errors and Limitations:
+
+#### Runtime Errors
+
+     
┌───────────────────────────────────────────────────────────────────────────────┬───────────────────────────────────────────────────────────────────────────────────────┐
+     │ Situation                                                               
      │ Error / Result                                                          
              │
+     
├───────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────┤
+     │ Test method signature does not include all injected parameters          
      │ TypeError: test_failure() missing X required positional arguments       
              │
+     │ Test method signature includes extra parameters not injected by any 
decorator │ TypeError: test_failure() got an unexpected keyword argument        
                  │
+     │ Parameter value is not a list or iterable                               
      │ TypeError: 'int' object is not iterable (ducktape internal error)       
              │
+     │ Using @matrix on a non-method function                                  
      │ Parameters are still injected, but self.test_context is None — runtime 
AttributeError │
+     
└───────────────────────────────────────────────────────────────────────────────┴───────────────────────────────────────────────────────────────────────────────────────┘
+
+#### Behavior Limitations
+
+    
┌──────────────────────────────────────────────────────────┬────────────────────────────────────────────────────────────────────────────────────────────┐
+    │ Situation                                                │ Behavior      
                                                                             │
+    
├──────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
+    │ One parameter list is empty                              │ The entire 
@matrix produces zero test cases — the test is silently skipped                 
│
+    │ Multiple @matrix decorators on one method                │ Each @matrix 
is applied independently, creating nested cartesian products                  │
+    │ Large number of combinations                             │ Exponential 
test growth — e.g., 3 params × 5 values each = 125 test executions per version │
+    │ Non-hashable parameter values (e.g., lists, dicts)       │ Ducktape may 
fail to deduplicate test cases — unexpected duplicates or crashes             │
+    │ Parameter names conflict with @ignite_versions injection │ Name 
collision in ctx.injected_args — last writer wins, unpredictable behavior       
      │
+    │ Mutable default values in parameter lists                │ Shared 
mutable state across test executions — potential side effects                   
    │
+    
└──────────────────────────────────────────────────────────┴────────────────────────────────────────────────────────────────────────────────────────────┘
+
+
+#### Test Reporting Limitations
+
+    
┌───────────────────────────────────────────────────────┬──────────────────────────────────────────────────────────────────────────────────────────────┐
+    │ Situation                                             │ Result           
                                                                            │
+    
├───────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────┤
+    │ Many matrix combinations produce identical test names │ Ducktape appends 
parameter values to the test ID for uniqueness, but logs may become verbose │
+    │ One matrix combination fails                          │ Other 
combinations still run — they are independent test executions                   
       │
+    │ Long-running matrix combinations                      │ Total test suite 
time = (combinations count) × (single test time) — can be very slow         │
+    
└───────────────────────────────────────────────────────┴──────────────────────────────────────────────────────────────────────────────────────────────┘
+---
+### @ignore_if Annotation
+
+Purpose: @ignore_if marks a test method as IGNORED if a specific condition 
evaluates to True. It is used to skip tests for certain Ignite versions or 
global parameter configurations without removing the test code.
+
+#### Basic Usage
+```python
+from ignitetest.utils import cluster, ignite_versions, ignore_if
+from ignitetest.utils.version import V_2_11_0
+class MyTest(IgniteTest):
+    @cluster(num_nodes=2)
+    @ignite_versions(str(DEV_BRANCH), str(LATEST))
+    @ignore_if(lambda version, globals: version <= V_2_11_0)

Review Comment:
   Nit: `globals` shadows the Python built-in. The other examples in this file 
use `g` for the second lambda argument — suggest the same here for consistency.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to