From 702b4d0413a5dd6b4a31b7b5f0bc662fc52be197 Mon Sep 17 00:00:00 2001 From: 0rlych1kk4 Date: Sat, 21 Feb 2026 14:56:53 +0800 Subject: [PATCH 01/19] test: add scheduled repair concurrency scenario for DatacenterAware multi-agent tests - Add scheduled repair concurrency test for multi-agent DatacenterAware mode - Make schedule interval and initial delay configurable per instance - Make schedule overrides opt-in to avoid affecting other tests - Configure fast schedules explicitly for this scenario --- ecchronos-binary/src/test/bin/ecc_config.py | 89 +++++++++++---------- 1 file changed, 48 insertions(+), 41 deletions(-) diff --git a/ecchronos-binary/src/test/bin/ecc_config.py b/ecchronos-binary/src/test/bin/ecc_config.py index 2aadea892..f64ccf310 100644 --- a/ecchronos-binary/src/test/bin/ecc_config.py +++ b/ecchronos-binary/src/test/bin/ecc_config.py @@ -137,47 +137,54 @@ def _modify_logback_configuration(self): def _modify_schedule_configuration(self): data = self._read_yaml_data(global_vars.SCHEDULE_YAML_FILE_PATH) - data["keyspaces"] = [ - { - "name": "test", - "tables": [ - { - "name": "table1", - "interval": {"time": 1, "unit": "days"}, - "initial_delay": {"time": 1, "unit": "hours"}, - "unwind_ratio": 0.1, - "alarm": {"warn": {"time": 4, "unit": "days"}, "error": {"time": 8, "unit": "days"}}, - } - ], - }, - { - "name": "test2", - "tables": [ - {"name": "table1", "repair_type": "incremental"}, - {"name": "table2", "repair_type": "parallel_vnode"}, - ], - }, - { - "name": "system_auth", - "tables": [ - {"name": "network_permissions", "enabled": False}, - {"name": "resource_role_permissons_index", "enabled": False}, - {"name": "role_members", "enabled": False}, - {"name": "role_permissions", "enabled": False}, - {"name": "roles", "enabled": False}, - ], - }, - { - "name": "ecchronos", - "tables": [ - {"name": "lock", "enabled": False}, - {"name": "lock_priority", "enabled": False}, - {"name": "on_demand_repair_status", "enabled": False}, - {"name": "reject_configuration", "enabled": False}, - {"name": "repair_history", "enabled": False}, - ], - }, - ] + + # Fix: preserve upstream/default schedule.yaml unless an explicit override is requested. + if getattr(self.context, "schedule_override", False): + data["keyspaces"] = [ + { + "name": "test", + "tables": [ + { + "name": "table1", + "interval": {"time": 1, "unit": "days"}, + "initial_delay": {"time": 1, "unit": "hours"}, + "unwind_ratio": 0.1, + "alarm": { + "warn": {"time": 4, "unit": "days"}, + "error": {"time": 8, "unit": "days"}, + }, + } + ], + }, + { + "name": "test2", + "tables": [ + {"name": "table1", "repair_type": "incremental"}, + {"name": "table2", "repair_type": "parallel_vnode"}, + ], + }, + { + "name": "system_auth", + "tables": [ + {"name": "network_permissions", "enabled": False}, + {"name": "resource_role_permissons_index", "enabled": False}, + {"name": "role_members", "enabled": False}, + {"name": "role_permissions", "enabled": False}, + {"name": "roles", "enabled": False}, + ], + }, + { + "name": "ecchronos", + "tables": [ + {"name": "lock", "enabled": False}, + {"name": "lock_priority", "enabled": False}, + {"name": "on_demand_repair_status", "enabled": False}, + {"name": "reject_configuration", "enabled": False}, + {"name": "repair_history", "enabled": False}, + ], + }, + ] + self._modify_yaml_data(global_vars.SCHEDULE_YAML_FILE_PATH, data) def _read_yaml_data(self, filename): From 68497413d65cffbf1a4c7adf465c6152871cc212 Mon Sep 17 00:00:00 2001 From: 0rlych1kk4 Date: Sun, 22 Feb 2026 14:04:29 +0800 Subject: [PATCH 02/19] test: harden ConfigRefresher Awaitility timeouts --- .../application/config/TestConfigRefresher.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/application/src/test/java/com/ericsson/bss/cassandra/ecchronos/application/config/TestConfigRefresher.java b/application/src/test/java/com/ericsson/bss/cassandra/ecchronos/application/config/TestConfigRefresher.java index 1016d02a8..bcf376b42 100644 --- a/application/src/test/java/com/ericsson/bss/cassandra/ecchronos/application/config/TestConfigRefresher.java +++ b/application/src/test/java/com/ericsson/bss/cassandra/ecchronos/application/config/TestConfigRefresher.java @@ -48,6 +48,16 @@ public void testGetFileContent() throws Exception writeToFile(file, "some new content"); await().atMost(5, TimeUnit.SECONDS).until(() -> "some new content".equals(reference.get())); + await() + .pollInterval(50, TimeUnit.MILLISECONDS) + .atMost(5, TimeUnit.SECONDS) + .until(() -> "some content".equals(reference.get())); + + writeToFile(file, "some new content"); + await() + .pollInterval(50, TimeUnit.MILLISECONDS) + .atMost(5, TimeUnit.SECONDS) + .until(() -> "some new content".equals(reference.get())); } } @@ -80,7 +90,14 @@ public void testRunnableThrowsAtFirstUpdate() throws Exception shouldThrow.set(false); writeToFile(file, "some new content"); +<<<<<<< HEAD await().atMost(5, TimeUnit.SECONDS).until(() -> "some new content".equals(reference.get())); +======= + await() + .pollInterval(50, TimeUnit.MILLISECONDS) + .atMost(5, TimeUnit.SECONDS) + .until(() -> "some new content".equals(reference.get())); +>>>>>>> 6e57bcad (test: harden ConfigRefresher Awaitility timeouts) } } From 0f6120ba307164f3e7b59a8d6b2695083a0af558 Mon Sep 17 00:00:00 2001 From: 0rlych1kk4 Date: Sun, 22 Feb 2026 17:38:15 +0800 Subject: [PATCH 03/19] test: make TestScheduleManager idle status deterministic (avoid race) --- .../ecchronos/core/scheduling/TestScheduleManager.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/core/src/test/java/com/ericsson/bss/cassandra/ecchronos/core/scheduling/TestScheduleManager.java b/core/src/test/java/com/ericsson/bss/cassandra/ecchronos/core/scheduling/TestScheduleManager.java index f4ece8bde..bcbde650a 100644 --- a/core/src/test/java/com/ericsson/bss/cassandra/ecchronos/core/scheduling/TestScheduleManager.java +++ b/core/src/test/java/com/ericsson/bss/cassandra/ecchronos/core/scheduling/TestScheduleManager.java @@ -264,7 +264,7 @@ public void testGetCurrentJobStatus() throws InterruptedException } @Test - public void testGetCurrentJobStatusNoRunning() throws InterruptedException + public void testGetCurrentJobStatusNoRunning() { CountDownLatch latch = new CountDownLatch(1); UUID jobId = UUID.randomUUID(); @@ -277,6 +277,11 @@ public void testGetCurrentJobStatusNoRunning() throws InterruptedException latch); myScheduler.schedule(testJob); new Thread(() -> myScheduler.run()).start(); + myScheduler.schedule(nodeID1, job1); + + // Deterministic: do NOT start the scheduler thread here. + // This test asserts the idle-state contract (no running job => empty status), + // and starting the scheduler introduces a timing race. assertThat(myScheduler.getCurrentJobStatus()).isEqualTo(""); latch.countDown(); } From 8e016dbefe83fecbf9b7ab3d7a809c4fc3e106b3 Mon Sep 17 00:00:00 2001 From: 0rlych1kk4 Date: Sat, 14 Mar 2026 13:02:56 +0800 Subject: [PATCH 04/19] test: only mount custom schedule config when overrides are requested --- ecchronos-binary/src/test/bin/ecc_config.py | 160 +++++++++++++++++++- 1 file changed, 158 insertions(+), 2 deletions(-) diff --git a/ecchronos-binary/src/test/bin/ecc_config.py b/ecchronos-binary/src/test/bin/ecc_config.py index f64ccf310..6db6024af 100644 --- a/ecchronos-binary/src/test/bin/ecc_config.py +++ b/ecchronos-binary/src/test/bin/ecc_config.py @@ -15,8 +15,11 @@ # limitations under the License. # -import yaml import re +import tempfile + +import yaml + import global_variables as global_vars @@ -33,6 +36,64 @@ def modify_configuration(self): if self.context.local != "true": self._modify_security_configuration() self._modify_application_configuration() + self.container_mounts["certificates"] = { + "host": global_vars.CERTIFICATE_DIRECTORY, + "container": global_vars.CONTAINER_CERTIFICATE_PATH, + } + + # Per-instance logs (multi-agent safe) + self.container_mounts["logs"] = { + "host": f"{global_vars.HOST_LOGS_PATH}/{self.instance_name}", + "container": global_vars.CONTAINER_LOGS_PATH, + } + + # -------------------------------------------------- + # ECC YAML + # -------------------------------------------------- + def _modify_ecc_yaml_file(self): + data = self._read_yaml_data(global_vars.ECC_YAML_FILE_PATH) + data = self._modify_connection_configuration(data) + data = self._modify_scheduler_configuration(data) + data = self._modify_twcs_configuration(data) + + if global_vars.JOLOKIA_ENABLED == "true": + data = self._modify_jolokia_configuration(data) + + self.container_mounts["ecc"] = { + "host": self.write_tmp(data), + "container": global_vars.CONTAINER_ECC_YAML_PATH, + } + + def _modify_connection_configuration(self, data): + data["connection"]["cql"]["contactPoints"] = [{"host": self.initial_contact_point, "port": 9042}] + data["connection"]["cql"]["datacenterAware"]["datacenters"] = self.datacenter_aware + data["connection"]["cql"]["instanceName"] = self.instance_name + data["connection"]["cql"]["localDatacenter"] = self.local_dc + return data + + def _modify_scheduler_configuration(self, data): + data["scheduler"]["frequency"]["time"] = 1 + return data + + def _modify_twcs_configuration(self, data): + data["repair"]["ignore_twcs_tables"] = True + return data + + def _modify_jolokia_configuration(self, data): + data["connection"]["jmx"]["jolokia"]["enabled"] = True + if global_vars.PEM_ENABLED == "true": + data["connection"]["jmx"]["jolokia"]["port"] = 8443 + data["connection"]["jmx"]["jolokia"]["usePem"] = True + return data + + # -------------------------------------------------- + # SECURITY YAML + # -------------------------------------------------- + def _modify_security_yaml_file(self): + data = self._read_yaml_data(global_vars.SECURITY_YAML_FILE_PATH) + if global_vars.LOCAL != "true": + data = self._modify_security_configuration(data) +>>>>>>> 8c6159a2 (test: only mount custom schedule config when overrides are requested) else: self._modify_cql_configuration() @@ -117,9 +178,26 @@ def _modify_spring_doc_configuration(self): data["springdoc"]["api-docs"]["enabled"] = True data["springdoc"]["api-docs"]["show-actuator"] = True self._modify_yaml_data(global_vars.APPLICATION_YAML_FILE_PATH, data) + return data + + # -------------------------------------------------- + # JVM / LOGGING + # -------------------------------------------------- + def _uncomment_head_options(self): + pattern = re.compile(r"^#\s*(-X.*)") + with open(global_vars.JVM_OPTIONS_FILE_PATH, "r", encoding="utf-8") as file: + lines = file.readlines() + + result = [pattern.match(line).group(1) + "\n" if pattern.match(line) else line for line in lines] + + self.container_mounts["jvm"] = { + "host": self.write_tmp(result, ".options"), + "container": global_vars.CONTAINER_JVM_OPTION_PATH, + } +>>>>>>> 8c6159a2 (test: only mount custom schedule config when overrides are requested) def _modify_logback_configuration(self): - with open(global_vars.LOGBACK_FILE_PATH, "r") as file: + with open(global_vars.LOGBACK_FILE_PATH, "r", encoding="utf-8") as file: lines = file.readlines() pattern = re.compile(r'^(\s*)()\s*$') @@ -195,3 +273,81 @@ def _read_yaml_data(self, filename): def _modify_yaml_data(self, filename, data): with open(filename, "w") as file: yaml.dump(data, file, sort_keys=False) +======= + self.container_mounts["logback"] = { + "host": self.write_tmp(result, ".xml"), + "container": global_vars.CONTAINER_LOGBACK_FILE_PATH, + } + + # -------------------------------------------------- + # SAFE SCHEDULE PATCH (non-global) + # -------------------------------------------------- + def _has_schedule_overrides(self): + return any( + value is not None + for value in ( + self.schedule_interval_time, + self.schedule_interval_unit, + self.schedule_initial_delay_time, + self.schedule_initial_delay_unit, + ) + ) + + def _modify_schedule_configuration(self): + # Leave the upstream schedule.yaml untouched unless this instance + # explicitly requests schedule overrides. + if not self._has_schedule_overrides(): + return + + data = self._read_yaml_data(global_vars.SCHEDULE_YAML_FILE_PATH) + + # safe_load may return None for an empty file + if data is None: + data = {} + + if isinstance(data, dict): + for keyspace in data.get("keyspaces") or []: + if not isinstance(keyspace, dict): + continue + if keyspace.get("name") != "test": + continue + + for table in keyspace.get("tables") or []: + if not isinstance(table, dict): + continue + if table.get("name") != "table1": + continue + + # Only patch timing-related schedule fields. + # Do not touch repair type or unrelated schedule entries. + if self.schedule_interval_time is not None: + table.setdefault("interval", {})["time"] = self.schedule_interval_time + if self.schedule_interval_unit is not None: + table.setdefault("interval", {})["unit"] = self.schedule_interval_unit + + if self.schedule_initial_delay_time is not None: + table.setdefault("initial_delay", {})["time"] = self.schedule_initial_delay_time + if self.schedule_initial_delay_unit is not None: + table.setdefault("initial_delay", {})["unit"] = self.schedule_initial_delay_unit + + break + + self.container_mounts["schedule"] = { + "host": self.write_tmp(data), + "container": global_vars.CONTAINER_SCHEDULE_YAML_PATH, + } + + # -------------------------------------------------- + def _read_yaml_data(self, filename): + with open(filename, "r", encoding="utf-8") as file: + return yaml.safe_load(file) + + def write_tmp(self, data, suffix=".yaml") -> str: + tmp = tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False, encoding="utf-8") + if suffix == ".yaml": + yaml.safe_dump(data, tmp) + else: + tmp.writelines(data) + tmp.close() + return tmp.name +>>>>>>> 8c6159a2 (test: only mount custom schedule config when overrides are requested) From 553e5461460cf1700a9cb6841393796ac44eb148 Mon Sep 17 00:00:00 2001 From: 0rlych1kk4 Date: Fri, 3 Apr 2026 23:45:07 +0800 Subject: [PATCH 05/19] test: avoid modifying global scheduler frequency (prevent cross-test impact) --- ecchronos-binary/src/test/bin/ecc_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ecchronos-binary/src/test/bin/ecc_config.py b/ecchronos-binary/src/test/bin/ecc_config.py index 6db6024af..f7de2571d 100644 --- a/ecchronos-binary/src/test/bin/ecc_config.py +++ b/ecchronos-binary/src/test/bin/ecc_config.py @@ -72,7 +72,7 @@ def _modify_connection_configuration(self, data): return data def _modify_scheduler_configuration(self, data): - data["scheduler"]["frequency"]["time"] = 1 + # return data def _modify_twcs_configuration(self, data): From 56f08c1a61b28e25286d4b7a3541dff017355c7a Mon Sep 17 00:00:00 2001 From: 0rlych1kk4 Date: Fri, 3 Apr 2026 23:52:53 +0800 Subject: [PATCH 06/19] style: format ecc_config.py with black --- ecchronos-binary/src/test/bin/ecc_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ecchronos-binary/src/test/bin/ecc_config.py b/ecchronos-binary/src/test/bin/ecc_config.py index f7de2571d..f9d13ef53 100644 --- a/ecchronos-binary/src/test/bin/ecc_config.py +++ b/ecchronos-binary/src/test/bin/ecc_config.py @@ -72,7 +72,7 @@ def _modify_connection_configuration(self, data): return data def _modify_scheduler_configuration(self, data): - # + # return data def _modify_twcs_configuration(self, data): From fa10177f86a5f42fc564107057adece983275bb1 Mon Sep 17 00:00:00 2001 From: 0rlych1kk4 Date: Sat, 4 Apr 2026 00:12:02 +0800 Subject: [PATCH 07/19] test: always mount schedule.yaml and apply overrides only when specified --- ecchronos-binary/src/test/bin/ecc_config.py | 51 +++++++++++---------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/ecchronos-binary/src/test/bin/ecc_config.py b/ecchronos-binary/src/test/bin/ecc_config.py index f9d13ef53..d68af4755 100644 --- a/ecchronos-binary/src/test/bin/ecc_config.py +++ b/ecchronos-binary/src/test/bin/ecc_config.py @@ -26,6 +26,31 @@ class EcchronosConfig: def __init__(self, context): self.context = context + def __init__( + self, + datacenter_aware=None, + local_dc=global_vars.DC1, + agent_type=global_vars.DEFAULT_AGENT_TYPE, + initial_contact_point=global_vars.DEFAULT_INITIAL_CONTACT_POINT, + instance_name=global_vars.DEFAULT_INSTANCE_NAME, + schedule_interval_time=None, + schedule_interval_unit=None, + schedule_initial_delay_time=None, + schedule_initial_delay_unit=None, + ): + self.container_mounts = {} + self.datacenter_aware = ( + [{"name": global_vars.DC1}, {"name": global_vars.DC2}] if datacenter_aware is None else datacenter_aware + ) + self.local_dc = local_dc + self.agent_type = agent_type + self.initial_contact_point = initial_contact_point + self.instance_name = instance_name + + self.schedule_interval_time = schedule_interval_time + self.schedule_interval_unit = schedule_interval_unit + self.schedule_initial_delay_time = schedule_initial_delay_time + self.schedule_initial_delay_unit = schedule_initial_delay_unit def modify_configuration(self): self._uncomment_head_options() @@ -41,15 +66,11 @@ def modify_configuration(self): "container": global_vars.CONTAINER_CERTIFICATE_PATH, } - # Per-instance logs (multi-agent safe) self.container_mounts["logs"] = { "host": f"{global_vars.HOST_LOGS_PATH}/{self.instance_name}", "container": global_vars.CONTAINER_LOGS_PATH, } - # -------------------------------------------------- - # ECC YAML - # -------------------------------------------------- def _modify_ecc_yaml_file(self): data = self._read_yaml_data(global_vars.ECC_YAML_FILE_PATH) data = self._modify_connection_configuration(data) @@ -72,7 +93,6 @@ def _modify_connection_configuration(self, data): return data def _modify_scheduler_configuration(self, data): - # return data def _modify_twcs_configuration(self, data): @@ -86,9 +106,6 @@ def _modify_jolokia_configuration(self, data): data["connection"]["jmx"]["jolokia"]["usePem"] = True return data - # -------------------------------------------------- - # SECURITY YAML - # -------------------------------------------------- def _modify_security_yaml_file(self): data = self._read_yaml_data(global_vars.SECURITY_YAML_FILE_PATH) if global_vars.LOCAL != "true": @@ -156,6 +173,7 @@ def _modify_cql_configuration(self): self._modify_yaml_data(global_vars.SECURITY_YAML_FILE_PATH, data) def _modify_application_configuration(self): + def _modify_application_yaml_file(self): data = self._read_yaml_data(global_vars.APPLICATION_YAML_FILE_PATH) if "server" not in data: @@ -180,9 +198,6 @@ def _modify_spring_doc_configuration(self): self._modify_yaml_data(global_vars.APPLICATION_YAML_FILE_PATH, data) return data - # -------------------------------------------------- - # JVM / LOGGING - # -------------------------------------------------- def _uncomment_head_options(self): pattern = re.compile(r"^#\s*(-X.*)") with open(global_vars.JVM_OPTIONS_FILE_PATH, "r", encoding="utf-8") as file: @@ -279,9 +294,6 @@ def _modify_yaml_data(self, filename, data): "container": global_vars.CONTAINER_LOGBACK_FILE_PATH, } - # -------------------------------------------------- - # SAFE SCHEDULE PATCH (non-global) - # -------------------------------------------------- def _has_schedule_overrides(self): return any( value is not None @@ -294,18 +306,12 @@ def _has_schedule_overrides(self): ) def _modify_schedule_configuration(self): - # Leave the upstream schedule.yaml untouched unless this instance - # explicitly requests schedule overrides. - if not self._has_schedule_overrides(): - return - data = self._read_yaml_data(global_vars.SCHEDULE_YAML_FILE_PATH) - # safe_load may return None for an empty file if data is None: data = {} - if isinstance(data, dict): + if self._has_schedule_overrides() and isinstance(data, dict): for keyspace in data.get("keyspaces") or []: if not isinstance(keyspace, dict): continue @@ -318,8 +324,6 @@ def _modify_schedule_configuration(self): if table.get("name") != "table1": continue - # Only patch timing-related schedule fields. - # Do not touch repair type or unrelated schedule entries. if self.schedule_interval_time is not None: table.setdefault("interval", {})["time"] = self.schedule_interval_time if self.schedule_interval_unit is not None: @@ -337,7 +341,6 @@ def _modify_schedule_configuration(self): "container": global_vars.CONTAINER_SCHEDULE_YAML_PATH, } - # -------------------------------------------------- def _read_yaml_data(self, filename): with open(filename, "r", encoding="utf-8") as file: return yaml.safe_load(file) From e32f8bc21d232e028f43559538259b17ce07e3f0 Mon Sep 17 00:00:00 2001 From: 0rlych1kk4 Date: Sat, 4 Apr 2026 00:33:52 +0800 Subject: [PATCH 08/19] fix: preserve upstream schedule behavior when YAML is empty --- .../core/scheduling/TestScheduleManager.java | 21 +- ecchronos-binary/src/test/bin/ecc_config.py | 181 ++---------------- 2 files changed, 24 insertions(+), 178 deletions(-) diff --git a/core/src/test/java/com/ericsson/bss/cassandra/ecchronos/core/scheduling/TestScheduleManager.java b/core/src/test/java/com/ericsson/bss/cassandra/ecchronos/core/scheduling/TestScheduleManager.java index bcbde650a..4e68bffc3 100644 --- a/core/src/test/java/com/ericsson/bss/cassandra/ecchronos/core/scheduling/TestScheduleManager.java +++ b/core/src/test/java/com/ericsson/bss/cassandra/ecchronos/core/scheduling/TestScheduleManager.java @@ -42,7 +42,7 @@ import com.ericsson.bss.cassandra.ecchronos.core.exceptions.LockException; -@RunWith (MockitoJUnitRunner.Silent.class) +@RunWith(MockitoJUnitRunner.Silent.class) public class TestScheduleManager { @Mock @@ -149,7 +149,7 @@ public void testRunningOneJobWithThrowingLock() throws LockException assertThat(myScheduler.getQueueSize()).isEqualTo(1); } - @Test (timeout = 2000L) + @Test(timeout = 2000L) public void testRunningTwoJobsInParallelShouldFail() throws InterruptedException { CountDownLatch job1Latch = new CountDownLatch(1); @@ -226,7 +226,7 @@ public void testThreeTasksOneThrowing() throws LockException verify(myLockFactory, times(3)).tryLock(any(), anyString(), anyInt(), anyMap()); } - @Test (timeout = 2000L) + @Test(timeout = 2000L) public void testDescheduleRunningJob() throws InterruptedException { CountDownLatch jobCdl = new CountDownLatch(1); @@ -276,8 +276,6 @@ public void testGetCurrentJobStatusNoRunning() jobId, latch); myScheduler.schedule(testJob); - new Thread(() -> myScheduler.run()).start(); - myScheduler.schedule(nodeID1, job1); // Deterministic: do NOT start the scheduler thread here. // This test asserts the idle-state contract (no running job => empty status), @@ -285,9 +283,10 @@ public void testGetCurrentJobStatusNoRunning() assertThat(myScheduler.getCurrentJobStatus()).isEqualTo(""); latch.countDown(); } + private void waitForJobStarted(TestJob job) throws InterruptedException { - while(!job.hasStarted()) + while (!job.hasStarted()) { Thread.sleep(10); } @@ -295,7 +294,7 @@ private void waitForJobStarted(TestJob job) throws InterruptedException private void waitForJobFinished(TestJob job) throws InterruptedException { - while(!job.hasRun()) + while (!job.hasRun()) { Thread.sleep(10); } @@ -310,7 +309,6 @@ private class TestJob extends ScheduledJob private final int numTasks; private final Runnable onCompletion; - public TestJob(Priority priority, CountDownLatch cdl) { this(priority, cdl, 1, () -> {}); @@ -399,23 +397,28 @@ public boolean execute() public class TestScheduledJob extends ScheduledJob { private final CountDownLatch taskCompletionLatch; + public TestScheduledJob(Configuration configuration, UUID id, CountDownLatch taskCompletionLatch) { super(configuration, id); this.taskCompletionLatch = taskCompletionLatch; } + @Override public Iterator iterator() { - return Collections. singleton(new ControllableTask(taskCompletionLatch)).iterator(); + return Collections.singleton(new ControllableTask(taskCompletionLatch)).iterator(); } + class ControllableTask extends ScheduledTask { private final CountDownLatch latch; + public ControllableTask(CountDownLatch latch) { this.latch = latch; } + @Override public boolean execute() { diff --git a/ecchronos-binary/src/test/bin/ecc_config.py b/ecchronos-binary/src/test/bin/ecc_config.py index d68af4755..c9b768e39 100644 --- a/ecchronos-binary/src/test/bin/ecc_config.py +++ b/ecchronos-binary/src/test/bin/ecc_config.py @@ -6,6 +6,7 @@ # Licensed 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 @@ -16,7 +17,6 @@ # import re -import tempfile import yaml @@ -26,31 +26,6 @@ class EcchronosConfig: def __init__(self, context): self.context = context - def __init__( - self, - datacenter_aware=None, - local_dc=global_vars.DC1, - agent_type=global_vars.DEFAULT_AGENT_TYPE, - initial_contact_point=global_vars.DEFAULT_INITIAL_CONTACT_POINT, - instance_name=global_vars.DEFAULT_INSTANCE_NAME, - schedule_interval_time=None, - schedule_interval_unit=None, - schedule_initial_delay_time=None, - schedule_initial_delay_unit=None, - ): - self.container_mounts = {} - self.datacenter_aware = ( - [{"name": global_vars.DC1}, {"name": global_vars.DC2}] if datacenter_aware is None else datacenter_aware - ) - self.local_dc = local_dc - self.agent_type = agent_type - self.initial_contact_point = initial_contact_point - self.instance_name = instance_name - - self.schedule_interval_time = schedule_interval_time - self.schedule_interval_unit = schedule_interval_unit - self.schedule_initial_delay_time = schedule_initial_delay_time - self.schedule_initial_delay_unit = schedule_initial_delay_unit def modify_configuration(self): self._uncomment_head_options() @@ -61,56 +36,6 @@ def modify_configuration(self): if self.context.local != "true": self._modify_security_configuration() self._modify_application_configuration() - self.container_mounts["certificates"] = { - "host": global_vars.CERTIFICATE_DIRECTORY, - "container": global_vars.CONTAINER_CERTIFICATE_PATH, - } - - self.container_mounts["logs"] = { - "host": f"{global_vars.HOST_LOGS_PATH}/{self.instance_name}", - "container": global_vars.CONTAINER_LOGS_PATH, - } - - def _modify_ecc_yaml_file(self): - data = self._read_yaml_data(global_vars.ECC_YAML_FILE_PATH) - data = self._modify_connection_configuration(data) - data = self._modify_scheduler_configuration(data) - data = self._modify_twcs_configuration(data) - - if global_vars.JOLOKIA_ENABLED == "true": - data = self._modify_jolokia_configuration(data) - - self.container_mounts["ecc"] = { - "host": self.write_tmp(data), - "container": global_vars.CONTAINER_ECC_YAML_PATH, - } - - def _modify_connection_configuration(self, data): - data["connection"]["cql"]["contactPoints"] = [{"host": self.initial_contact_point, "port": 9042}] - data["connection"]["cql"]["datacenterAware"]["datacenters"] = self.datacenter_aware - data["connection"]["cql"]["instanceName"] = self.instance_name - data["connection"]["cql"]["localDatacenter"] = self.local_dc - return data - - def _modify_scheduler_configuration(self, data): - return data - - def _modify_twcs_configuration(self, data): - data["repair"]["ignore_twcs_tables"] = True - return data - - def _modify_jolokia_configuration(self, data): - data["connection"]["jmx"]["jolokia"]["enabled"] = True - if global_vars.PEM_ENABLED == "true": - data["connection"]["jmx"]["jolokia"]["port"] = 8443 - data["connection"]["jmx"]["jolokia"]["usePem"] = True - return data - - def _modify_security_yaml_file(self): - data = self._read_yaml_data(global_vars.SECURITY_YAML_FILE_PATH) - if global_vars.LOCAL != "true": - data = self._modify_security_configuration(data) ->>>>>>> 8c6159a2 (test: only mount custom schedule config when overrides are requested) else: self._modify_cql_configuration() @@ -173,13 +98,9 @@ def _modify_cql_configuration(self): self._modify_yaml_data(global_vars.SECURITY_YAML_FILE_PATH, data) def _modify_application_configuration(self): - def _modify_application_yaml_file(self): data = self._read_yaml_data(global_vars.APPLICATION_YAML_FILE_PATH) - if "server" not in data: - data["server"] = {} - if "ssl" not in data["server"]: - data["server"]["ssl"] = {} + data.setdefault("server", {}).setdefault("ssl", {}) data["server"]["ssl"]["enabled"] = True data["server"]["ssl"]["key-store"] = f"{global_vars.CERTIFICATE_DIRECTORY}/serverkeystore" @@ -189,6 +110,7 @@ def _modify_application_yaml_file(self): data["server"]["ssl"]["trust-store"] = f"{global_vars.CERTIFICATE_DIRECTORY}/servertruststore" data["server"]["ssl"]["trust-store-password"] = "ecctest" data["server"]["ssl"]["client-auth"] = "need" + self._modify_yaml_data(global_vars.APPLICATION_YAML_FILE_PATH, data) def _modify_spring_doc_configuration(self): @@ -196,20 +118,6 @@ def _modify_spring_doc_configuration(self): data["springdoc"]["api-docs"]["enabled"] = True data["springdoc"]["api-docs"]["show-actuator"] = True self._modify_yaml_data(global_vars.APPLICATION_YAML_FILE_PATH, data) - return data - - def _uncomment_head_options(self): - pattern = re.compile(r"^#\s*(-X.*)") - with open(global_vars.JVM_OPTIONS_FILE_PATH, "r", encoding="utf-8") as file: - lines = file.readlines() - - result = [pattern.match(line).group(1) + "\n" if pattern.match(line) else line for line in lines] - - self.container_mounts["jvm"] = { - "host": self.write_tmp(result, ".options"), - "container": global_vars.CONTAINER_JVM_OPTION_PATH, - } ->>>>>>> 8c6159a2 (test: only mount custom schedule config when overrides are requested) def _modify_logback_configuration(self): with open(global_vars.LOGBACK_FILE_PATH, "r", encoding="utf-8") as file: @@ -217,21 +125,23 @@ def _modify_logback_configuration(self): pattern = re.compile(r'^(\s*)()\s*$') - with open(global_vars.LOGBACK_FILE_PATH, "w") as file: + with open(global_vars.LOGBACK_FILE_PATH, "w", encoding="utf-8") as file: for line in lines: match = pattern.match(line) if match: indent = match.group(1) content = match.group(2) - new_line = f"{indent}\n" - file.write(new_line) + file.write(f"{indent}\n") else: file.write(line) def _modify_schedule_configuration(self): data = self._read_yaml_data(global_vars.SCHEDULE_YAML_FILE_PATH) - # Fix: preserve upstream/default schedule.yaml unless an explicit override is requested. + if data is None: + return + + # Preserve upstream/default schedule.yaml unless an explicit override is requested. if getattr(self.context, "schedule_override", False): data["keyspaces"] = [ { @@ -280,77 +190,10 @@ def _modify_schedule_configuration(self): self._modify_yaml_data(global_vars.SCHEDULE_YAML_FILE_PATH, data) - def _read_yaml_data(self, filename): - with open(filename, "r") as f: - data = yaml.safe_load(f) - return data - - def _modify_yaml_data(self, filename, data): - with open(filename, "w") as file: - yaml.dump(data, file, sort_keys=False) -======= - self.container_mounts["logback"] = { - "host": self.write_tmp(result, ".xml"), - "container": global_vars.CONTAINER_LOGBACK_FILE_PATH, - } - - def _has_schedule_overrides(self): - return any( - value is not None - for value in ( - self.schedule_interval_time, - self.schedule_interval_unit, - self.schedule_initial_delay_time, - self.schedule_initial_delay_unit, - ) - ) - - def _modify_schedule_configuration(self): - data = self._read_yaml_data(global_vars.SCHEDULE_YAML_FILE_PATH) - - if data is None: - data = {} - - if self._has_schedule_overrides() and isinstance(data, dict): - for keyspace in data.get("keyspaces") or []: - if not isinstance(keyspace, dict): - continue - if keyspace.get("name") != "test": - continue - - for table in keyspace.get("tables") or []: - if not isinstance(table, dict): - continue - if table.get("name") != "table1": - continue - - if self.schedule_interval_time is not None: - table.setdefault("interval", {})["time"] = self.schedule_interval_time - if self.schedule_interval_unit is not None: - table.setdefault("interval", {})["unit"] = self.schedule_interval_unit - - if self.schedule_initial_delay_time is not None: - table.setdefault("initial_delay", {})["time"] = self.schedule_initial_delay_time - if self.schedule_initial_delay_unit is not None: - table.setdefault("initial_delay", {})["unit"] = self.schedule_initial_delay_unit - - break - - self.container_mounts["schedule"] = { - "host": self.write_tmp(data), - "container": global_vars.CONTAINER_SCHEDULE_YAML_PATH, - } - def _read_yaml_data(self, filename): with open(filename, "r", encoding="utf-8") as file: return yaml.safe_load(file) - def write_tmp(self, data, suffix=".yaml") -> str: - tmp = tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False, encoding="utf-8") - if suffix == ".yaml": - yaml.safe_dump(data, tmp) - else: - tmp.writelines(data) - tmp.close() - return tmp.name ->>>>>>> 8c6159a2 (test: only mount custom schedule config when overrides are requested) + def _modify_yaml_data(self, filename, data): + with open(filename, "w", encoding="utf-8") as file: + yaml.dump(data, file, sort_keys=False) From 9e6df4407f8a89d8fa0056c3c4013043d55bc404 Mon Sep 17 00:00:00 2001 From: 0rlych1kk4 Date: Sun, 19 Apr 2026 16:57:15 +0800 Subject: [PATCH 09/19] test: harden config refresher and schedule manager determinism --- .../config/TestConfigRefresher.java | 50 ++++++----- .../core/scheduling/TestScheduleManager.java | 90 +++++++++++++------ 2 files changed, 95 insertions(+), 45 deletions(-) diff --git a/application/src/test/java/com/ericsson/bss/cassandra/ecchronos/application/config/TestConfigRefresher.java b/application/src/test/java/com/ericsson/bss/cassandra/ecchronos/application/config/TestConfigRefresher.java index bcf376b42..57a5e42e5 100644 --- a/application/src/test/java/com/ericsson/bss/cassandra/ecchronos/application/config/TestConfigRefresher.java +++ b/application/src/test/java/com/ericsson/bss/cassandra/ecchronos/application/config/TestConfigRefresher.java @@ -17,7 +17,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; -import java.io.*; +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -44,20 +48,20 @@ public void testGetFileContent() throws Exception configRefresher.watch(file.toPath(), () -> reference.set(readFileContent(file))); writeToFile(file, "some content"); - await().atMost(5, TimeUnit.SECONDS).until(() -> "some content".equals(reference.get())); - writeToFile(file, "some new content"); - await().atMost(5, TimeUnit.SECONDS).until(() -> "some new content".equals(reference.get())); + // Fix: use a slightly more tolerant poll interval/timeout for CI. await() - .pollInterval(50, TimeUnit.MILLISECONDS) - .atMost(5, TimeUnit.SECONDS) - .until(() -> "some content".equals(reference.get())); + .pollInterval(50, TimeUnit.MILLISECONDS) + .atMost(10, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(reference.get()).isEqualTo("some content")); writeToFile(file, "some new content"); + + // Fix: remove the brittle / contradictory extra wait and assert only the final expected content. await() - .pollInterval(50, TimeUnit.MILLISECONDS) - .atMost(5, TimeUnit.SECONDS) - .until(() -> "some new content".equals(reference.get())); + .pollInterval(50, TimeUnit.MILLISECONDS) + .atMost(10, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(reference.get()).isEqualTo("some new content")); } } @@ -67,12 +71,15 @@ public void testRunnableThrowsAtFirstUpdate() throws Exception File file = temporaryFolder.newFile(); AtomicBoolean shouldThrow = new AtomicBoolean(true); + AtomicInteger callbackAttempts = new AtomicInteger(0); try (ConfigRefresher configRefresher = new ConfigRefresher(temporaryFolder.getRoot().toPath())) { AtomicReference reference = new AtomicReference<>(readFileContent(file)); configRefresher.watch(file.toPath(), () -> { + callbackAttempts.incrementAndGet(); + if (shouldThrow.get()) { throw new NullPointerException(); @@ -83,21 +90,24 @@ public void testRunnableThrowsAtFirstUpdate() throws Exception writeToFile(file, "some content"); - Thread.sleep(100); + // Fix: replace Thread.sleep(...) with deterministic waiting for the first callback attempt. + await() + .pollInterval(50, TimeUnit.MILLISECONDS) + .atMost(10, TimeUnit.SECONDS) + .until(() -> callbackAttempts.get() >= 1); - assertThat(reference).hasValue(""); + // Fix: assert that the failing callback did not update the reference. + assertThat(reference.get()).isEqualTo(""); shouldThrow.set(false); writeToFile(file, "some new content"); -<<<<<<< HEAD - await().atMost(5, TimeUnit.SECONDS).until(() -> "some new content".equals(reference.get())); -======= + + // Fix: resolved merge conflict and hardened Awaitility timing for CI. await() - .pollInterval(50, TimeUnit.MILLISECONDS) - .atMost(5, TimeUnit.SECONDS) - .until(() -> "some new content".equals(reference.get())); ->>>>>>> 6e57bcad (test: harden ConfigRefresher Awaitility timeouts) + .pollInterval(50, TimeUnit.MILLISECONDS) + .atMost(10, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(reference.get()).isEqualTo("some new content")); } } @@ -112,7 +122,7 @@ private void writeToFile(File file, String content) throws IOException private String readFileContent(File file) { try (FileReader fileReader = new FileReader(file); - BufferedReader bufferedReader = new BufferedReader(fileReader)) + BufferedReader bufferedReader = new BufferedReader(fileReader)) { StringBuilder result = new StringBuilder(); String line; diff --git a/core/src/test/java/com/ericsson/bss/cassandra/ecchronos/core/scheduling/TestScheduleManager.java b/core/src/test/java/com/ericsson/bss/cassandra/ecchronos/core/scheduling/TestScheduleManager.java index 4e68bffc3..d297498ff 100644 --- a/core/src/test/java/com/ericsson/bss/cassandra/ecchronos/core/scheduling/TestScheduleManager.java +++ b/core/src/test/java/com/ericsson/bss/cassandra/ecchronos/core/scheduling/TestScheduleManager.java @@ -4,7 +4,7 @@ * Licensed 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 + * 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, @@ -15,6 +15,7 @@ package com.ericsson.bss.cassandra.ecchronos.core.scheduling; import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyMap; @@ -24,6 +25,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.time.Duration; import java.util.ArrayList; import java.util.Collections; import java.util.Iterator; @@ -149,7 +151,7 @@ public void testRunningOneJobWithThrowingLock() throws LockException assertThat(myScheduler.getQueueSize()).isEqualTo(1); } - @Test(timeout = 2000L) + @Test(timeout = 5000L) public void testRunningTwoJobsInParallelShouldFail() throws InterruptedException { CountDownLatch job1Latch = new CountDownLatch(1); @@ -159,17 +161,24 @@ public void testRunningTwoJobsInParallelShouldFail() throws InterruptedException myScheduler.schedule(job1); myScheduler.schedule(job2); - new Thread(() -> myScheduler.run()).start(); - new Thread(() -> myScheduler.run()).start(); + Thread firstRunner = startSchedulerThread(); + Thread secondRunner = startSchedulerThread(); + // Fix: deterministic wait instead of manual spin/sleep loop. waitForJobStarted(job1); assertThat(job2.hasStarted()).isFalse(); + job1Latch.countDown(); + + // Fix: deterministic completion wait. waitForJobFinished(job1); assertThat(job1.hasRun()).isTrue(); assertThat(job2.hasRun()).isFalse(); assertThat(myScheduler.getQueueSize()).isEqualTo(2); + + firstRunner.join(1000L); + secondRunner.join(1000L); } @Test @@ -226,14 +235,14 @@ public void testThreeTasksOneThrowing() throws LockException verify(myLockFactory, times(3)).tryLock(any(), anyString(), anyInt(), anyMap()); } - @Test(timeout = 2000L) + @Test(timeout = 5000L) public void testDescheduleRunningJob() throws InterruptedException { CountDownLatch jobCdl = new CountDownLatch(1); TestJob job = new TestJob(ScheduledJob.Priority.HIGH, jobCdl); myScheduler.schedule(job); - new Thread(() -> myScheduler.run()).start(); + Thread schedulerThread = startSchedulerThread(); waitForJobStarted(job); myScheduler.deschedule(job); @@ -242,10 +251,12 @@ public void testDescheduleRunningJob() throws InterruptedException assertThat(job.hasRun()).isTrue(); assertThat(myScheduler.getQueueSize()).isEqualTo(0); + + schedulerThread.join(1000L); } @Test - public void testGetCurrentJobStatus() throws InterruptedException + public void testGetCurrentJobStatus() { CountDownLatch latch = new CountDownLatch(1); UUID jobId = UUID.randomUUID(); @@ -256,11 +267,32 @@ public void testGetCurrentJobStatus() throws InterruptedException .build(), jobId, latch); + myScheduler.schedule(testJob); - new Thread(() -> myScheduler.run()).start(); - Thread.sleep(50); - assertThat(myScheduler.getCurrentJobStatus()).isEqualTo(jobId.toString()); + + Thread schedulerThread = startSchedulerThread(); + + // Fix: replace brittle Thread.sleep(50) with Awaitility. + await() + .pollInterval(Duration.ofMillis(25)) + .atMost(Duration.ofSeconds(5)) + .untilAsserted(() -> assertThat(myScheduler.getCurrentJobStatus()).isEqualTo(jobId.toString())); + latch.countDown(); + + await() + .pollInterval(Duration.ofMillis(25)) + .atMost(Duration.ofSeconds(5)) + .untilAsserted(() -> assertThat(myScheduler.getCurrentJobStatus()).isEqualTo("")); + + try + { + schedulerThread.join(1000L); + } + catch (InterruptedException e) + { + Thread.currentThread().interrupt(); + } } @Test @@ -277,27 +309,34 @@ public void testGetCurrentJobStatusNoRunning() latch); myScheduler.schedule(testJob); - // Deterministic: do NOT start the scheduler thread here. - // This test asserts the idle-state contract (no running job => empty status), - // and starting the scheduler introduces a timing race. + // Fix: do not start the scheduler here. + // This keeps the test deterministic and verifies the idle-state contract only. assertThat(myScheduler.getCurrentJobStatus()).isEqualTo(""); latch.countDown(); } - private void waitForJobStarted(TestJob job) throws InterruptedException + private Thread startSchedulerThread() { - while (!job.hasStarted()) - { - Thread.sleep(10); - } + Thread thread = new Thread(() -> myScheduler.run()); + thread.setDaemon(true); + thread.start(); + return thread; } - private void waitForJobFinished(TestJob job) throws InterruptedException + private void waitForJobStarted(TestJob job) { - while (!job.hasRun()) - { - Thread.sleep(10); - } + await() + .pollInterval(Duration.ofMillis(25)) + .atMost(Duration.ofSeconds(5)) + .until(job::hasStarted); + } + + private void waitForJobFinished(TestJob job) + { + await() + .pollInterval(Duration.ofMillis(25)) + .atMost(Duration.ofSeconds(5)) + .until(job::hasRun); } private class TestJob extends ScheduledJob @@ -331,7 +370,7 @@ public TestJob(Priority priority, CountDownLatch cdl, int numTasks, Runnable onC super(new ConfigurationBuilder().withPriority(priority).withRunInterval(1, TimeUnit.SECONDS).build()); this.numTasks = numTasks; this.onCompletion = onCompletion; - countDownLatch = cdl; + this.countDownLatch = cdl; } public int getTaskRuns() @@ -384,7 +423,8 @@ public boolean execute() } catch (InterruptedException e) { - // Intentionally left empty + Thread.currentThread().interrupt(); + return false; } onCompletion.run(); taskRuns.incrementAndGet(); From b38cdf80297189b6b609f0996e00073ecdfb8bf8 Mon Sep 17 00:00:00 2001 From: 0rlych1kk4 Date: Sun, 19 Apr 2026 17:37:37 +0800 Subject: [PATCH 10/19] chore: apply license headers --- .../ecchronos/core/scheduling/TestScheduleManager.java | 4 ++-- ecchronos-binary/src/test/bin/ecc_config.py | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/core/src/test/java/com/ericsson/bss/cassandra/ecchronos/core/scheduling/TestScheduleManager.java b/core/src/test/java/com/ericsson/bss/cassandra/ecchronos/core/scheduling/TestScheduleManager.java index d297498ff..ea43e5b8f 100644 --- a/core/src/test/java/com/ericsson/bss/cassandra/ecchronos/core/scheduling/TestScheduleManager.java +++ b/core/src/test/java/com/ericsson/bss/cassandra/ecchronos/core/scheduling/TestScheduleManager.java @@ -1,10 +1,10 @@ /* - * Copyright 2018 Telefonaktiebolaget LM Ericsson + * Copyright 2024 Telefonaktiebolaget LM Ericsson * * Licensed 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 + * 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, diff --git a/ecchronos-binary/src/test/bin/ecc_config.py b/ecchronos-binary/src/test/bin/ecc_config.py index c9b768e39..bf7822d6e 100644 --- a/ecchronos-binary/src/test/bin/ecc_config.py +++ b/ecchronos-binary/src/test/bin/ecc_config.py @@ -1,12 +1,10 @@ #!/usr/bin/env python3 -# vi: syntax=python # -# Copyright 2025 Telefonaktiebolaget LM Ericsson +# Copyright 2024 Telefonaktiebolaget LM Ericsson # # Licensed 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 From e52cc14e2e33310d22c2d0490cfaf6338d66185c Mon Sep 17 00:00:00 2001 From: 0rlych1kk4 Date: Sun, 19 Apr 2026 18:02:04 +0800 Subject: [PATCH 11/19] test: increase Cassandra startup timeout for Python integration --- ecchronos-binary/src/test/bin/cass_config.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ecchronos-binary/src/test/bin/cass_config.py b/ecchronos-binary/src/test/bin/cass_config.py index b2d359dac..a07c34bcf 100644 --- a/ecchronos-binary/src/test/bin/cass_config.py +++ b/ecchronos-binary/src/test/bin/cass_config.py @@ -24,7 +24,9 @@ import logging import subprocess -DEFAULT_WAIT_TIME_IN_SECS = 60 +# Fix: increase cluster startup wait time for slower CI environments, +# especially for 4-node Cassandra 5.x startup in GitHub Actions. +DEFAULT_WAIT_TIME_IN_SECS = 120 COMPOSE_FILE_NAME = "docker-compose.yml" CASSANDRA_SEED_DC1_RC1_ND1 = "cassandra-seed-dc1-rack1-node1" From 6b3eac48d4e210cce5df03d4fb5dbae83f6671bf Mon Sep 17 00:00:00 2001 From: 0rlych1kk4 Date: Sun, 19 Apr 2026 20:55:47 +0800 Subject: [PATCH 12/19] fix: prevent continuous rescheduling after successful execution --- .../cassandra/ecchronos/core/scheduling/ScheduledJob.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/com/ericsson/bss/cassandra/ecchronos/core/scheduling/ScheduledJob.java b/core/src/main/java/com/ericsson/bss/cassandra/ecchronos/core/scheduling/ScheduledJob.java index 0590ed7e8..120e77088 100644 --- a/core/src/main/java/com/ericsson/bss/cassandra/ecchronos/core/scheduling/ScheduledJob.java +++ b/core/src/main/java/com/ericsson/bss/cassandra/ecchronos/core/scheduling/ScheduledJob.java @@ -66,7 +66,10 @@ protected void postExecute(final boolean successful, final ScheduledTask task) if (successful) { myLastSuccessfulRun = System.currentTimeMillis(); - myNextRunTime = -1; + + // Fix: do not make the job immediately runnable again after a successful execution. + // Respect the configured run interval before the next run. + myNextRunTime = myLastSuccessfulRun + myRunIntervalInMs; } else { From c2e0ee6d23c13abe433becdbe9a5699a0ea1daee Mon Sep 17 00:00:00 2001 From: 0rlych1kk4 Date: Sun, 19 Apr 2026 21:48:59 +0800 Subject: [PATCH 13/19] fix: remove invalid exec-maven-plugin parameters --- ecchronos-binary/pom.xml | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/ecchronos-binary/pom.xml b/ecchronos-binary/pom.xml index dff350e96..8a33845b3 100644 --- a/ecchronos-binary/pom.xml +++ b/ecchronos-binary/pom.xml @@ -124,6 +124,7 @@ + maven-resources-plugin @@ -183,8 +184,6 @@ - - @@ -198,7 +197,6 @@ python-integration-tests - maven-dependency-plugin @@ -287,7 +285,7 @@ python - -m + -m pytest test_ecchronos.py -s @@ -302,14 +300,11 @@ ${project.basedir} ${it.cassandra.version} - true - true - io.fabric8 docker-maven-plugin @@ -317,6 +312,7 @@ + @@ -326,7 +322,6 @@ local-python-integration-tests - maven-dependency-plugin @@ -350,6 +345,7 @@ + org.codehaus.mojo exec-maven-plugin @@ -394,8 +390,6 @@ ${project.basedir} ${it.cassandra.version} - true - true From 15647254cc9b6322b9ee6e678a82985a39c3f955 Mon Sep 17 00:00:00 2001 From: 0rlych1kk4 Date: Sun, 19 Apr 2026 22:41:22 +0800 Subject: [PATCH 14/19] Revert "fix: remove invalid exec-maven-plugin parameters" This reverts commit c2e0ee6d23c13abe433becdbe9a5699a0ea1daee. --- ecchronos-binary/pom.xml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/ecchronos-binary/pom.xml b/ecchronos-binary/pom.xml index 8a33845b3..dff350e96 100644 --- a/ecchronos-binary/pom.xml +++ b/ecchronos-binary/pom.xml @@ -124,7 +124,6 @@ - maven-resources-plugin @@ -184,6 +183,8 @@ + + @@ -197,6 +198,7 @@ python-integration-tests + maven-dependency-plugin @@ -285,7 +287,7 @@ python - -m + -m pytest test_ecchronos.py -s @@ -300,11 +302,14 @@ ${project.basedir} ${it.cassandra.version} + true + true + io.fabric8 docker-maven-plugin @@ -312,7 +317,6 @@ - @@ -322,6 +326,7 @@ local-python-integration-tests + maven-dependency-plugin @@ -345,7 +350,6 @@ - org.codehaus.mojo exec-maven-plugin @@ -390,6 +394,8 @@ ${project.basedir} ${it.cassandra.version} + true + true From f7cb33a4a892c51fe4149e10be3b1ebd940f4482 Mon Sep 17 00:00:00 2001 From: 0rlych1kk4 Date: Sun, 19 Apr 2026 22:46:27 +0800 Subject: [PATCH 15/19] Revert "fix: prevent continuous rescheduling after successful execution" This reverts commit 6b3eac48d4e210cce5df03d4fb5dbae83f6671bf. --- .../cassandra/ecchronos/core/scheduling/ScheduledJob.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/core/src/main/java/com/ericsson/bss/cassandra/ecchronos/core/scheduling/ScheduledJob.java b/core/src/main/java/com/ericsson/bss/cassandra/ecchronos/core/scheduling/ScheduledJob.java index 120e77088..0590ed7e8 100644 --- a/core/src/main/java/com/ericsson/bss/cassandra/ecchronos/core/scheduling/ScheduledJob.java +++ b/core/src/main/java/com/ericsson/bss/cassandra/ecchronos/core/scheduling/ScheduledJob.java @@ -66,10 +66,7 @@ protected void postExecute(final boolean successful, final ScheduledTask task) if (successful) { myLastSuccessfulRun = System.currentTimeMillis(); - - // Fix: do not make the job immediately runnable again after a successful execution. - // Respect the configured run interval before the next run. - myNextRunTime = myLastSuccessfulRun + myRunIntervalInMs; + myNextRunTime = -1; } else { From 74ce53643b710dfccf5c8f91d0e1adc0a819fac6 Mon Sep 17 00:00:00 2001 From: 0rlych1kk4 Date: Mon, 20 Apr 2026 11:23:05 +0800 Subject: [PATCH 16/19] test: harden python integration setup and OpenAPI fetch retries --- .../src/test/bin/install_dependencies.sh | 21 ++- .../src/test/bin/test_ecchronos.py | 139 ++++++++++++------ 2 files changed, 104 insertions(+), 56 deletions(-) diff --git a/ecchronos-binary/src/test/bin/install_dependencies.sh b/ecchronos-binary/src/test/bin/install_dependencies.sh index 049b35ae2..55976959e 100644 --- a/ecchronos-binary/src/test/bin/install_dependencies.sh +++ b/ecchronos-binary/src/test/bin/install_dependencies.sh @@ -14,31 +14,30 @@ # limitations under the License. # -set -e +set -euo pipefail source variables.sh echo "Setting up Python environment and dependencies..." -# Setup virtual environment for non-CI environments -if [ -z "${CI}" ]; then - echo "Installing virtualenv" - pip install --user virtualenv - virtualenv "$VENV_DIR" --python=python3 - source "$VENV_DIR"/bin/activate -fi +# Use an isolated virtual environment in both CI and local runs to keep +# dependency resolution deterministic and avoid host-environment leakage. +python3 -m venv "$VENV_DIR" +source "$VENV_DIR"/bin/activate + +python -m pip install --upgrade pip echo "Installing Python dependencies from requirements.txt" -pip install -r requirements.txt +python -m pip install -r requirements.txt echo "Installing ecChronos Python library" BASE_DIR="$TEST_DIR"/ecchronos-binary-${PROJECT_VERSION} PYLIB_DIR="$BASE_DIR"/pylib if [ -d "$PYLIB_DIR" ]; then - pip install "$PYLIB_DIR" + python -m pip install "$PYLIB_DIR" else echo "Warning: ecChronos Python library directory not found at $PYLIB_DIR" fi -echo "All dependencies installed successfully!" \ No newline at end of file +echo "All dependencies installed successfully!" diff --git a/ecchronos-binary/src/test/bin/test_ecchronos.py b/ecchronos-binary/src/test/bin/test_ecchronos.py index 9af02f941..e1254f097 100644 --- a/ecchronos-binary/src/test/bin/test_ecchronos.py +++ b/ecchronos-binary/src/test/bin/test_ecchronos.py @@ -14,27 +14,97 @@ # limitations under the License. # -import pytest -import global_variables as global_vars -import subprocess -import os import logging +import os +import subprocess import sys +import time + +import pytest + +import global_variables as global_vars from conftest import build_behave_command logger = logging.getLogger(__name__) +OPENAPI_FETCH_RETRIES = 10 +OPENAPI_FETCH_RETRY_DELAY_SECS = 3 +BEHAVE_TIMEOUT_SECS = 1800 +OPENAPI_TIMEOUT_SECS = 30 +PYLINT_TIMEOUT_SECS = 300 + + +def _run_command(command, timeout, capture_output=False, text=False): + return subprocess.run( + command, + stdout=subprocess.PIPE if capture_output else sys.stdout, + stderr=subprocess.PIPE if capture_output else sys.stderr, + timeout=timeout, + text=text, + check=False, + ) + + +def _fetch_openapi_with_retries(openapi_path): + if global_vars.LOCAL == "true": + curl_cmd = ["curl", "http://localhost:8080/v3/api-docs.yaml", "-o", openapi_path] + else: + curl_cmd = [ + "curl", + "https://localhost:8080/v3/api-docs.yaml", + "-o", + openapi_path, + "--cert", + f"{global_vars.CERTIFICATE_DIRECTORY}/clientcert.crt", + "--key", + f"{global_vars.CERTIFICATE_DIRECTORY}/clientkey.pem", + "--cacert", + f"{global_vars.CERTIFICATE_DIRECTORY}/serverca.crt", + ] + + last_error = None + + for attempt in range(1, OPENAPI_FETCH_RETRIES + 1): + logger.info( + "Fetching OpenAPI spec (attempt %s/%s)", + attempt, + OPENAPI_FETCH_RETRIES, + ) + + try: + result = _run_command( + curl_cmd, + timeout=OPENAPI_TIMEOUT_SECS, + capture_output=True, + text=True, + ) + + if result.returncode == 0 and os.path.exists(openapi_path) and os.path.getsize(openapi_path) > 0: + return + + last_error = ( + f"curl exit code={result.returncode}, " + f"stdout={result.stdout.strip()}, stderr={result.stderr.strip()}" + ) + except subprocess.TimeoutExpired as exc: + last_error = f"curl timed out: {exc}" + + if attempt < OPENAPI_FETCH_RETRIES: + time.sleep(OPENAPI_FETCH_RETRY_DELAY_SECS) + + pytest.fail(f"Failed to fetch OpenAPI spec after retries: {last_error}") + @pytest.mark.dependency(name="test_behave_tests") def test_behave_tests(test_environment): - """Test that runs behave tests""" + """Test that runs behave tests.""" cassandra_cluster = test_environment command = build_behave_command(cassandra_cluster) logger.info("Running behave tests") try: - result = subprocess.run(command, stdout=sys.stdout, stderr=sys.stderr, timeout=1800) - logger.info(f"Behave tests completed with exit code: {result.returncode}") + result = _run_command(command, timeout=BEHAVE_TIMEOUT_SECS) + logger.info("Behave tests completed with exit code: %s", result.returncode) if result.returncode != 0: logger.error("Behave tests failed") @@ -45,42 +115,21 @@ def test_behave_tests(test_environment): except subprocess.TimeoutExpired: logger.error("Behave tests timed out after 30 minutes") pytest.fail("Behave tests timed out") - except subprocess.SubprocessError as e: - logger.error(f"Failed to run behave tests: {e}") - pytest.fail(f"Failed to run behave tests: {e}") + except subprocess.SubprocessError as exc: + logger.error("Failed to run behave tests: %s", exc) + pytest.fail(f"Failed to run behave tests: {exc}") @pytest.mark.dependency(name="test_fetch_and_validate_openapi", depends=["test_behave_tests"]) def test_fetch_and_validate_openapi(test_environment): - """Test that fetches and validates OpenAPI spec""" + """Test that fetches and validates OpenAPI spec.""" openapi_path = "../../../docs/autogenerated/openapi.yaml" try: - # Fetch OpenAPI spec - if global_vars.LOCAL == "true": - curl_cmd = ["curl", "http://localhost:8080/v3/api-docs.yaml", "-o", openapi_path] - else: - curl_cmd = [ - "curl", - "https://localhost:8080/v3/api-docs.yaml", - "-o", - openapi_path, - "--cert", - f"{global_vars.CERTIFICATE_DIRECTORY}/clientcert.crt", - "--key", - f"{global_vars.CERTIFICATE_DIRECTORY}/clientkey.pem", - "--cacert", - f"{global_vars.CERTIFICATE_DIRECTORY}/serverca.crt", - ] - - result = subprocess.run(curl_cmd, capture_output=True, text=True, timeout=30) - if result.returncode != 0: - logger.error(f"Failed to fetch OpenAPI spec: {result.stderr}") - pytest.fail(f"Failed to fetch OpenAPI spec: {result.stderr}") + _fetch_openapi_with_retries(openapi_path) - # Validate OpenAPI spec validate_cmd = ["openapi-spec-validator", openapi_path, "--schema", "3.0.0"] - result = subprocess.run(validate_cmd, stdout=sys.stdout, stderr=sys.stderr, timeout=30) + result = _run_command(validate_cmd, timeout=OPENAPI_TIMEOUT_SECS) if result.returncode == 0: logger.info("OpenAPI spec validation passed") @@ -88,14 +137,14 @@ def test_fetch_and_validate_openapi(test_environment): logger.error("OpenAPI spec validation failed") pytest.fail("OpenAPI spec validation failed") - except (subprocess.TimeoutExpired, subprocess.SubprocessError) as e: - logger.error(f"Failed to fetch/validate OpenAPI spec: {e}") - pytest.fail(f"Failed to fetch/validate OpenAPI spec: {e}") + except (subprocess.TimeoutExpired, subprocess.SubprocessError) as exc: + logger.error("Failed to fetch/validate OpenAPI spec: %s", exc) + pytest.fail(f"Failed to fetch/validate OpenAPI spec: {exc}") @pytest.mark.dependency(name="test_pylint_tests", depends=["test_fetch_and_validate_openapi"]) def test_pylint_tests(): - """Test that runs pylint on specified directories""" + """Test that runs pylint on specified directories.""" directories = [ f"{global_vars.PROJECT_BUILD_DIRECTORY}/../src/bin", f"{global_vars.PROJECT_BUILD_DIRECTORY}/../src/pylib/ecchronoslib", @@ -105,18 +154,18 @@ def test_pylint_tests(): for directory in directories: if not os.path.exists(directory): - logger.warning(f"Directory {directory} does not exist, skipping pylint") + logger.warning("Directory %s does not exist, skipping pylint", directory) continue - logger.info(f"Running pylint for {directory}") + logger.info("Running pylint for %s", directory) try: - result = subprocess.run(["pylint", directory], stdout=sys.stdout, stderr=sys.stderr, timeout=300) + result = _run_command(["pylint", directory], timeout=PYLINT_TIMEOUT_SECS) if result.returncode != 0: - logger.error(f"Pylint failed for {directory}") + logger.error("Pylint failed for %s", directory) pytest.fail(f"Pylint failed for {directory}") - except (subprocess.TimeoutExpired, subprocess.SubprocessError) as e: - logger.error(f"Failed to run pylint for {directory}: {e}") - pytest.fail(f"Failed to run pylint for {directory}: {e}") + except (subprocess.TimeoutExpired, subprocess.SubprocessError) as exc: + logger.error("Failed to run pylint for %s: %s", directory, exc) + pytest.fail(f"Failed to run pylint for {directory}: {exc}") logger.info("All pylint tests passed") From 015e639f00ef15a53f9d0a3af28e67fee733c70f Mon Sep 17 00:00:00 2001 From: 0rlych1kk4 Date: Mon, 20 Apr 2026 13:00:51 +0800 Subject: [PATCH 17/19] fix: remove invalid exec-maven-plugin redirect parameters --- ecchronos-binary/pom.xml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/ecchronos-binary/pom.xml b/ecchronos-binary/pom.xml index dff350e96..d1ec4ca18 100644 --- a/ecchronos-binary/pom.xml +++ b/ecchronos-binary/pom.xml @@ -183,8 +183,6 @@ - - @@ -287,7 +285,7 @@ python - -m + -m pytest test_ecchronos.py -s @@ -302,8 +300,6 @@ ${project.basedir} ${it.cassandra.version} - true - true @@ -394,8 +390,6 @@ ${project.basedir} ${it.cassandra.version} - true - true From 5789f87614ba80f542b16d9d137ce92a9c84715a Mon Sep 17 00:00:00 2001 From: 0rlych1kk4 Date: Sun, 26 Apr 2026 22:19:17 +0800 Subject: [PATCH 18/19] fix: use venv python for python integration tests --- ecchronos-binary/pom.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ecchronos-binary/pom.xml b/ecchronos-binary/pom.xml index d1ec4ca18..58f91c490 100644 --- a/ecchronos-binary/pom.xml +++ b/ecchronos-binary/pom.xml @@ -283,7 +283,8 @@ exec - python + + ${project.build.directory}/test/venv/bin/python -m pytest From db0077e6d2c3f90430882bcbf9b46daeeab4dd67 Mon Sep 17 00:00:00 2001 From: 0rlych1kk4 Date: Sun, 26 Apr 2026 22:59:32 +0800 Subject: [PATCH 19/19] fix: expose python venv bin for integration subprocesses --- ecchronos-binary/pom.xml | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/ecchronos-binary/pom.xml b/ecchronos-binary/pom.xml index 58f91c490..ccf452ace 100644 --- a/ecchronos-binary/pom.xml +++ b/ecchronos-binary/pom.xml @@ -283,15 +283,11 @@ exec - - ${project.build.directory}/test/venv/bin/python + + bash - -m - pytest - test_ecchronos.py - -s - -v - --tb=short + -c + export PATH="$PWD/venv/bin:$PATH" && ./venv/bin/python -m pytest test_ecchronos.py -s -v --tb=short ${project.build.directory}/test