diff --git a/examples/schedule-config.yaml b/examples/schedule-config.yaml
new file mode 100644
index 000000000..b9877ff95
--- /dev/null
+++ b/examples/schedule-config.yaml
@@ -0,0 +1,58 @@
+# Example configuration for time-based cluster activation
+# This file shows how to configure cluster schedules for the Trino Gateway
+
+# Main configuration
+server:
+ applicationConnectors:
+ - type: http
+ port: 8080
+ adminConnectors:
+ - type: http
+ port: 8081
+
+# Database configuration
+dataStore:
+ jdbcUrl: jdbc:h2:./gateway-ha/gateway-ha;DB_CLOSE_DELAY=-1;MODE=MYSQL
+ user: sa
+ password: ""
+ driverClass: org.h2.Driver
+
+# Monitor configuration
+monitor:
+ active: true
+ connectionTimeout: 10s
+ refreshInterval: 10s
+ backends: ["trino-1", "trino-2"]
+
+# Backend configurations
+backends:
+ - name: trino-1
+ proxyTo: http://localhost:8080
+ active: true
+ routingGroup: adhoc
+ externalUrl: http://localhost:8080
+ - name: trino-2
+ proxyTo: http://localhost:8081
+ active: true
+ routingGroup: adhoc
+ externalUrl: http://localhost:8081
+
+# Schedule configuration for time-based activation
+scheduleConfiguration:
+ enabled: true
+ checkInterval: 5m # Check every 5 minutes
+ schedules:
+ - clusterName: trino-1
+ cronExpression: "0 0 9-17 * * ?" # Active from 9 AM to 5 PM every day
+ activeDuringCron: true # Active when cron matches
+ - clusterName: trino-2
+ cronExpression: "0 0 17-9 * * ?" # Active from 5 PM to 9 AM every day
+ activeDuringCron: true # Active when cron matches
+
+# Authentication configuration (example)
+authentication:
+ type: none
+
+# Authorization configuration (example)
+authorization:
+ type: none
diff --git a/gateway-ha/pom.xml b/gateway-ha/pom.xml
index 9fe50911c..617064e3b 100644
--- a/gateway-ha/pom.xml
+++ b/gateway-ha/pom.xml
@@ -42,6 +42,12 @@
0.22.1
+
+ com.cronutils
+ cron-utils
+ 9.2.1
+
+
com.fasterxml.jackson.core
jackson-annotations
@@ -201,6 +207,11 @@
jakarta.annotation-api
+
+ jakarta.inject
+ jakarta.inject-api
+
+
jakarta.servlet
jakarta.servlet-api
diff --git a/gateway-ha/src/main/java/io/trino/gateway/ha/config/ClusterSchedulerConfiguration.java b/gateway-ha/src/main/java/io/trino/gateway/ha/config/ClusterSchedulerConfiguration.java
new file mode 100644
index 000000000..dc4db48ec
--- /dev/null
+++ b/gateway-ha/src/main/java/io/trino/gateway/ha/config/ClusterSchedulerConfiguration.java
@@ -0,0 +1,55 @@
+/*
+ * 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
+ * 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.
+ */
+package io.trino.gateway.ha.config;
+
+import com.google.inject.Inject;
+import io.airlift.log.Logger;
+import io.trino.gateway.ha.scheduler.ClusterScheduler;
+import jakarta.annotation.Nullable;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+
+public class ClusterSchedulerConfiguration
+{
+ private static final Logger log = Logger.get(ClusterSchedulerConfiguration.class);
+
+ private final ClusterScheduler scheduler;
+
+ @Inject
+ public ClusterSchedulerConfiguration(@Nullable ClusterScheduler scheduler)
+ {
+ this.scheduler = scheduler;
+ }
+
+ @PostConstruct
+ public void start()
+ {
+ if (scheduler != null) {
+ scheduler.start();
+ }
+ }
+
+ @PreDestroy
+ public void stop()
+ {
+ if (scheduler != null) {
+ try {
+ scheduler.close();
+ }
+ catch (Exception e) {
+ log.error(e, "Exception occurred while shutting down ClusterScheduler");
+ }
+ }
+ }
+}
diff --git a/gateway-ha/src/main/java/io/trino/gateway/ha/config/HaGatewayConfiguration.java b/gateway-ha/src/main/java/io/trino/gateway/ha/config/HaGatewayConfiguration.java
index 61a859ce7..0290b757e 100644
--- a/gateway-ha/src/main/java/io/trino/gateway/ha/config/HaGatewayConfiguration.java
+++ b/gateway-ha/src/main/java/io/trino/gateway/ha/config/HaGatewayConfiguration.java
@@ -13,6 +13,7 @@
*/
package io.trino.gateway.ha.config;
+import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Streams;
@@ -40,6 +41,7 @@ public class HaGatewayConfiguration
private List extraWhitelistPaths = new ArrayList<>();
private OAuth2GatewayCookieConfiguration oauth2GatewayCookieConfiguration = new OAuth2GatewayCookieConfiguration();
private GatewayCookieConfiguration gatewayCookieConfiguration = new GatewayCookieConfiguration();
+ private ScheduleConfiguration scheduleConfiguration = new ScheduleConfiguration();
private List statementPaths = ImmutableList.of(V1_STATEMENT_PATH);
private boolean includeClusterHostInResponse;
private ProxyResponseConfiguration proxyResponseConfiguration = new ProxyResponseConfiguration();
@@ -191,6 +193,18 @@ public OAuth2GatewayCookieConfiguration getOauth2GatewayCookieConfiguration()
return oauth2GatewayCookieConfiguration;
}
+ @JsonProperty
+ public ScheduleConfiguration getScheduleConfiguration()
+ {
+ return scheduleConfiguration;
+ }
+
+ @JsonProperty
+ public void setScheduleConfiguration(ScheduleConfiguration scheduleConfiguration)
+ {
+ this.scheduleConfiguration = scheduleConfiguration;
+ }
+
public void setOauth2GatewayCookieConfiguration(OAuth2GatewayCookieConfiguration oauth2GatewayCookieConfiguration)
{
this.oauth2GatewayCookieConfiguration = oauth2GatewayCookieConfiguration;
diff --git a/gateway-ha/src/main/java/io/trino/gateway/ha/config/ScheduleConfiguration.java b/gateway-ha/src/main/java/io/trino/gateway/ha/config/ScheduleConfiguration.java
new file mode 100644
index 000000000..d96e240b6
--- /dev/null
+++ b/gateway-ha/src/main/java/io/trino/gateway/ha/config/ScheduleConfiguration.java
@@ -0,0 +1,121 @@
+/*
+ * 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
+ * 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.
+ */
+package io.trino.gateway.ha.config;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import io.airlift.units.Duration;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static java.util.Objects.requireNonNull;
+
+public class ScheduleConfiguration
+{
+ private boolean enabled;
+ private Duration checkInterval = new Duration(5, java.util.concurrent.TimeUnit.MINUTES);
+ private String timezone = "GMT"; // Default to GMT if not specified
+ private List schedules = new ArrayList<>();
+
+ @JsonProperty
+ public boolean isEnabled()
+ {
+ return enabled;
+ }
+
+ @JsonProperty
+ public void setEnabled(boolean enabled)
+ {
+ this.enabled = enabled;
+ }
+
+ @JsonProperty
+ public Duration getCheckInterval()
+ {
+ return checkInterval;
+ }
+
+ @JsonProperty
+ public void setCheckInterval(Duration checkInterval)
+ {
+ this.checkInterval = requireNonNull(checkInterval, "checkInterval is null");
+ }
+
+ @JsonProperty
+ public String getTimezone()
+ {
+ return timezone;
+ }
+
+ @JsonProperty
+ public void setTimezone(String timezone)
+ {
+ this.timezone = requireNonNull(timezone, "timezone is null");
+ }
+
+ @JsonProperty
+ public List getSchedules()
+ {
+ return schedules;
+ }
+
+ @JsonProperty
+ public void setSchedules(List schedules)
+ {
+ this.schedules = schedules;
+ }
+
+ public static class ClusterSchedule
+ {
+ private String clusterName;
+ private String cronExpression;
+ private boolean activeDuringCron;
+
+ @JsonProperty
+ public String getClusterName()
+ {
+ return clusterName;
+ }
+
+ @JsonProperty
+ public void setClusterName(String clusterName)
+ {
+ this.clusterName = requireNonNull(clusterName, "clusterName is null");
+ }
+
+ @JsonProperty
+ public String getCronExpression()
+ {
+ return cronExpression;
+ }
+
+ @JsonProperty
+ public void setCronExpression(String cronExpression)
+ {
+ this.cronExpression = requireNonNull(cronExpression, "cronExpression is null");
+ }
+
+ @JsonProperty
+ public boolean isActiveDuringCron()
+ {
+ return activeDuringCron;
+ }
+
+ @JsonProperty
+ public void setActiveDuringCron(boolean activeDuringCron)
+ {
+ this.activeDuringCron = activeDuringCron;
+ }
+ }
+}
diff --git a/gateway-ha/src/main/java/io/trino/gateway/ha/module/ClusterSchedulerModule.java b/gateway-ha/src/main/java/io/trino/gateway/ha/module/ClusterSchedulerModule.java
new file mode 100644
index 000000000..85642842a
--- /dev/null
+++ b/gateway-ha/src/main/java/io/trino/gateway/ha/module/ClusterSchedulerModule.java
@@ -0,0 +1,71 @@
+/*
+ * 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
+ * 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.
+ */
+package io.trino.gateway.ha.module;
+
+import com.google.inject.AbstractModule;
+import com.google.inject.Provides;
+import io.airlift.log.Logger;
+import io.trino.gateway.ha.config.HaGatewayConfiguration;
+import io.trino.gateway.ha.config.ScheduleConfiguration;
+import io.trino.gateway.ha.router.GatewayBackendManager;
+import io.trino.gateway.ha.scheduler.ClusterScheduler;
+import jakarta.inject.Singleton;
+
+import static java.util.Objects.requireNonNull;
+
+public class ClusterSchedulerModule
+ extends AbstractModule
+{
+ private static final Logger log = Logger.get(ClusterSchedulerModule.class);
+ private final HaGatewayConfiguration configuration;
+
+ // We require all modules to take HaGatewayConfiguration as the only parameter
+ public ClusterSchedulerModule(HaGatewayConfiguration configuration)
+ {
+ this.configuration = requireNonNull(configuration, "configuration is null");
+ }
+
+ @Override
+ public void configure()
+ {
+ // Configuration-based binding is handled in the provider methods
+ if (configuration.getScheduleConfiguration() != null
+ && configuration.getScheduleConfiguration().isEnabled()) {
+ log.info("ClusterScheduler configuration is enabled");
+ }
+ else {
+ log.info("ClusterScheduler is disabled or not configured");
+ }
+ }
+
+ @Provides
+ @Singleton
+ public ScheduleConfiguration provideScheduleConfiguration()
+ {
+ return configuration.getScheduleConfiguration();
+ }
+
+ @Provides
+ @Singleton
+ public ClusterScheduler provideClusterScheduler(
+ GatewayBackendManager backendManager,
+ ScheduleConfiguration scheduleConfiguration)
+ {
+ if (scheduleConfiguration == null || !scheduleConfiguration.isEnabled()) {
+ return null;
+ }
+ log.info("Creating ClusterScheduler instance");
+ return new ClusterScheduler(backendManager, scheduleConfiguration);
+ }
+}
diff --git a/gateway-ha/src/main/java/io/trino/gateway/ha/scheduler/ClusterScheduler.java b/gateway-ha/src/main/java/io/trino/gateway/ha/scheduler/ClusterScheduler.java
new file mode 100644
index 000000000..8decfee3d
--- /dev/null
+++ b/gateway-ha/src/main/java/io/trino/gateway/ha/scheduler/ClusterScheduler.java
@@ -0,0 +1,202 @@
+/*
+ * 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
+ * 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.
+ */
+package io.trino.gateway.ha.scheduler;
+
+import com.cronutils.model.Cron;
+import com.cronutils.model.CronType;
+import com.cronutils.model.definition.CronDefinition;
+import com.cronutils.model.definition.CronDefinitionBuilder;
+import com.cronutils.model.time.ExecutionTime;
+import com.cronutils.parser.CronParser;
+import io.airlift.log.Logger;
+import io.trino.gateway.ha.config.ProxyBackendConfiguration;
+import io.trino.gateway.ha.config.ScheduleConfiguration;
+import io.trino.gateway.ha.router.GatewayBackendManager;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import jakarta.inject.Inject;
+
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import static java.util.Objects.requireNonNull;
+
+public class ClusterScheduler
+ implements AutoCloseable
+{
+ private static final Logger log = Logger.get(ClusterScheduler.class);
+ private ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
+ private final GatewayBackendManager backendManager;
+ private final ScheduleConfiguration config;
+ private final Map executionTimes = new ConcurrentHashMap<>();
+ private final CronParser cronParser;
+ private final ZoneId timezone;
+
+ @Inject
+ public ClusterScheduler(GatewayBackendManager backendManager, ScheduleConfiguration config)
+ {
+ this.backendManager = requireNonNull(backendManager, "backendManager is null");
+ this.config = requireNonNull(config, "config is null");
+ CronDefinition cronDefinition = CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX);
+ this.cronParser = new CronParser(cronDefinition);
+
+ // Initialize timezone from config, default to GMT if not specified or invalid
+ ZoneId configuredTimezone;
+ try {
+ String timezoneStr = config.getTimezone();
+ if (timezoneStr == null || timezoneStr.trim().isEmpty()) {
+ configuredTimezone = ZoneId.of("GMT");
+ log.info("No timezone specified in configuration, using default: GMT");
+ }
+ else {
+ configuredTimezone = ZoneId.of(timezoneStr);
+ log.info("Using configured timezone: %s", timezoneStr);
+ }
+ }
+ catch (Exception e) {
+ configuredTimezone = ZoneId.of("GMT");
+ log.warn(e, "Invalid timezone '%s' in configuration, falling back to GMT", config.getTimezone());
+ }
+ this.timezone = configuredTimezone;
+ }
+
+ @PostConstruct
+ public void start()
+ {
+ if (!config.isEnabled()) {
+ log.info("Cluster scheduling is disabled");
+ return;
+ }
+
+ // Initialize execution times
+ for (ScheduleConfiguration.ClusterSchedule schedule : config.getSchedules()) {
+ try {
+ Cron cron = cronParser.parse(schedule.getCronExpression());
+ executionTimes.put(schedule.getClusterName(), ExecutionTime.forCron(cron));
+ log.info("Scheduled cluster {} with cron expression: {}, activeDuringCron: {}",
+ schedule.getClusterName(),
+ schedule.getCronExpression(),
+ schedule.isActiveDuringCron());
+ }
+ catch (Exception e) {
+ log.error("Skipping cluster {} due to invalid cron expression '{}': {}",
+ schedule.getClusterName(),
+ schedule.getCronExpression(),
+ e.getMessage());
+ }
+ }
+
+ // Schedule the task
+ scheduler.scheduleAtFixedRate(
+ () -> checkAndUpdateClusterStatus(ZonedDateTime.now(timezone)),
+ 0,
+ config.getCheckInterval().toMillis(),
+ TimeUnit.MILLISECONDS);
+ log.info("Started cluster scheduler with check interval: %s (using %s timezone)",
+ config.getCheckInterval(),
+ timezone);
+ }
+
+ public void checkAndUpdateClusterStatus(ZonedDateTime currentTime)
+ {
+ try {
+ log.debug("Checking cluster status at: %s (%s)", currentTime, timezone);
+
+ for (Map.Entry entry : executionTimes.entrySet()) {
+ String clusterName = entry.getKey();
+ ExecutionTime executionTime = entry.getValue();
+
+ // Find the schedule for this cluster
+ Optional scheduleOpt = config.getSchedules().stream()
+ .filter(s -> s.getClusterName().equals(clusterName))
+ .findFirst();
+
+ if (scheduleOpt.isEmpty()) {
+ log.warn("No schedule configuration found for cluster: %s", clusterName);
+ continue;
+ }
+
+ ScheduleConfiguration.ClusterSchedule schedule = scheduleOpt.get();
+ boolean cronMatches = executionTime.isMatch(currentTime);
+ boolean shouldBeActive = cronMatches == schedule.isActiveDuringCron();
+
+ log.info("Cluster: %s, cronMatches: %s, activeDuringCron: %s, shouldBeActive: %s",
+ clusterName,
+ cronMatches,
+ schedule.isActiveDuringCron(),
+ shouldBeActive);
+
+ // Update cluster status if needed
+ Optional clusterOpt = backendManager.getBackendByName(clusterName);
+ if (clusterOpt.isPresent()) {
+ ProxyBackendConfiguration cluster = clusterOpt.get();
+ boolean currentlyActive = cluster.isActive();
+
+ log.debug("Cluster: %s, currentlyActive: %s, shouldBeActive: %s",
+ clusterName,
+ currentlyActive,
+ shouldBeActive);
+
+ if (currentlyActive != shouldBeActive) {
+ if (shouldBeActive) {
+ backendManager.activateBackend(clusterName);
+ log.info("Activated cluster %s based on schedule (cron match: %s, activeDuringCron: %s)",
+ clusterName,
+ cronMatches,
+ schedule.isActiveDuringCron());
+ }
+ else {
+ backendManager.deactivateBackend(clusterName);
+ log.info("Deactivated cluster %s based on schedule (cron match: %s, activeDuringCron: %s)",
+ clusterName,
+ cronMatches,
+ schedule.isActiveDuringCron());
+ }
+ }
+ else {
+ log.debug("Cluster %s status unchanged: active=%s", clusterName, currentlyActive);
+ }
+ }
+ else {
+ log.warn("Cluster %s not found in backend manager", clusterName);
+ }
+ }
+ }
+ catch (Exception e) {
+ log.error(e, "Error in cluster scheduler task");
+ }
+ }
+
+ @PreDestroy
+ @Override
+ public void close()
+ {
+ scheduler.shutdownNow();
+ try {
+ if (!scheduler.awaitTermination(10, TimeUnit.SECONDS)) {
+ log.warn("Cluster scheduler did not terminate in time");
+ }
+ }
+ catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ log.warn(e, "Interrupted while waiting for scheduler to terminate");
+ }
+ }
+}
diff --git a/gateway-ha/src/test/java/io/trino/gateway/ha/scheduler/TestClusterScheduler.java b/gateway-ha/src/test/java/io/trino/gateway/ha/scheduler/TestClusterScheduler.java
new file mode 100644
index 000000000..1e34ede14
--- /dev/null
+++ b/gateway-ha/src/test/java/io/trino/gateway/ha/scheduler/TestClusterScheduler.java
@@ -0,0 +1,387 @@
+/*
+ * 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
+ * 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.
+ */
+package io.trino.gateway.ha.scheduler;
+
+import io.airlift.units.Duration;
+import io.trino.gateway.ha.config.ProxyBackendConfiguration;
+import io.trino.gateway.ha.config.ScheduleConfiguration;
+import io.trino.gateway.ha.router.GatewayBackendManager;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatNoException;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class TestClusterScheduler
+{
+ private static final String CLUSTER_NAME = "test-cluster";
+ // Match every minute from 9 AM to 5 PM to ensure the test time is always matched
+ // Unix cron format: minute hour day month day-of-week (5 parts)
+ private static final String CRON_EXPRESSION = "* 9-17 * * *"; // Every minute from 9 AM to 5 PM
+ private static final ZoneId TEST_TIMEZONE = ZoneId.of("America/Los_Angeles");
+
+ @Mock
+ private GatewayBackendManager backendManager;
+
+ @Mock
+ private ScheduleConfiguration scheduleConfig;
+
+ @Mock
+ private ScheduleConfiguration.ClusterSchedule clusterSchedule;
+
+ @Mock
+ private ProxyBackendConfiguration backendConfig;
+
+ private ClusterScheduler scheduler;
+
+ @BeforeEach
+ void setUp()
+ {
+ // Reset all mocks before each test to ensure clean state
+ reset(backendManager, scheduleConfig, clusterSchedule, backendConfig);
+ }
+
+ private void setupTestCluster(boolean activeDuringCron)
+ {
+ // Setup for a test cluster - call this in tests that need it
+ when(scheduleConfig.isEnabled()).thenReturn(true);
+ when(scheduleConfig.getCheckInterval()).thenReturn(new Duration(1, TimeUnit.MINUTES));
+ when(scheduleConfig.getTimezone()).thenReturn(TEST_TIMEZONE.toString());
+ when(scheduleConfig.getSchedules()).thenReturn(java.util.List.of(clusterSchedule));
+ when(clusterSchedule.getClusterName()).thenReturn(CLUSTER_NAME);
+ when(clusterSchedule.getCronExpression()).thenReturn(CRON_EXPRESSION);
+ when(clusterSchedule.isActiveDuringCron()).thenReturn(activeDuringCron);
+ when(backendManager.getBackendByName(CLUSTER_NAME)).thenReturn(java.util.Optional.of(backendConfig));
+
+ // Initialize the scheduler with the test cluster
+ scheduler = new ClusterScheduler(backendManager, scheduleConfig);
+ // Note: Don't start the scheduler here to avoid background executions during tests
+ }
+
+ @Test
+ void testSchedulerInitialization()
+ {
+ // Only mock what's actually needed in the constructor
+ when(scheduleConfig.getTimezone()).thenReturn(TEST_TIMEZONE.toString());
+
+ // Initialize the scheduler
+ scheduler = new ClusterScheduler(backendManager, scheduleConfig);
+
+ assertThat(scheduler).isNotNull();
+ verify(scheduleConfig).getTimezone();
+ }
+
+ @Test
+ void testClusterActivationWhenCronMatches()
+ {
+ // Setup test cluster with activeDuringCron = true
+ setupTestCluster(true);
+
+ // Initialize the scheduler
+ scheduler.start();
+
+ // Time within the cron schedule (9 AM - 5 PM)
+ ZonedDateTime activeTime = ZonedDateTime.of(2025, 9, 29, 10, 0, 0, 0, TEST_TIMEZONE);
+ when(backendConfig.isActive()).thenReturn(false);
+
+ // Execute
+ scheduler.checkAndUpdateClusterStatus(activeTime);
+
+ // Verify
+ // We expect at least one call to activateBackend
+ verify(backendManager, atLeastOnce()).activateBackend(CLUSTER_NAME);
+ // We expect exactly one call to getBackendByName
+ verify(backendManager, atLeastOnce()).getBackendByName(CLUSTER_NAME);
+ // We expect exactly one call to isActive
+ verify(backendConfig, atLeastOnce()).isActive();
+ }
+
+ @Test
+ void testClusterDeactivationWhenCronDoesNotMatch()
+ {
+ // Setup test cluster with activeDuringCron = true
+ setupTestCluster(true);
+
+ // Initialize the scheduler
+ scheduler.start();
+
+ // Time outside the cron schedule (before 9 AM)
+ ZonedDateTime inactiveTime = ZonedDateTime.of(2025, 9, 29, 8, 0, 0, 0, TEST_TIMEZONE);
+ when(backendConfig.isActive()).thenReturn(true);
+
+ // Execute
+ scheduler.checkAndUpdateClusterStatus(inactiveTime);
+
+ // Verify
+ verify(backendManager, atLeastOnce()).deactivateBackend(CLUSTER_NAME);
+ // Verify getBackendByName is called at least once (actual implementation may call it multiple times)
+ verify(backendManager, atLeastOnce()).getBackendByName(CLUSTER_NAME);
+ verify(backendConfig, atLeastOnce()).isActive();
+ }
+
+ @Test
+ void testNoActionWhenClusterStatusMatchesSchedule()
+ {
+ // Setup test cluster with activeDuringCron = true
+ setupTestCluster(true);
+
+ // Initialize the scheduler (needed to parse cron expressions)
+ scheduler.start();
+
+ // Time within the cron schedule (9 AM - 5 PM)
+ ZonedDateTime activeTime = ZonedDateTime.of(2025, 9, 29, 10, 0, 0, 0, TEST_TIMEZONE);
+ when(backendConfig.isActive()).thenReturn(true);
+
+ // Execute
+ scheduler.checkAndUpdateClusterStatus(activeTime);
+
+ // Verify no action taken when status already matches
+ verify(backendManager, never()).activateBackend(anyString());
+ verify(backendManager, atLeastOnce()).getBackendByName(CLUSTER_NAME);
+ verify(backendConfig, atLeastOnce()).isActive();
+ }
+
+ @Test
+ void testClusterNotFoundInBackendManager()
+ {
+ // Setup test cluster with empty backend
+ when(scheduleConfig.isEnabled()).thenReturn(true);
+ when(scheduleConfig.getCheckInterval()).thenReturn(new Duration(1, TimeUnit.MINUTES));
+ when(scheduleConfig.getTimezone()).thenReturn(TEST_TIMEZONE.toString());
+ when(scheduleConfig.getSchedules()).thenReturn(java.util.List.of(clusterSchedule));
+ when(clusterSchedule.getClusterName()).thenReturn(CLUSTER_NAME);
+ when(clusterSchedule.getCronExpression()).thenReturn(CRON_EXPRESSION);
+ when(clusterSchedule.isActiveDuringCron()).thenReturn(true);
+ when(backendManager.getBackendByName(CLUSTER_NAME)).thenReturn(java.util.Optional.empty());
+
+ // Initialize scheduler with the test configuration
+ scheduler = new ClusterScheduler(backendManager, scheduleConfig);
+
+ // Initialize the scheduler
+ scheduler.start();
+
+ // Time within the cron schedule (9 AM - 5 PM)
+ ZonedDateTime testTime = ZonedDateTime.of(2025, 9, 29, 10, 0, 0, 0, TEST_TIMEZONE);
+
+ // Execute
+ scheduler.checkAndUpdateClusterStatus(testTime);
+
+ // Verify no action taken when cluster not found
+ verify(backendManager, never()).activateBackend(anyString());
+ verify(backendManager, never()).deactivateBackend(anyString());
+ verify(backendManager, atLeastOnce()).getBackendByName(CLUSTER_NAME);
+ }
+
+ @Test
+ void testSchedulerDoesNotTriggerWhenDisabled()
+ {
+ // Setup test cluster with scheduling disabled
+ when(scheduleConfig.isEnabled()).thenReturn(false);
+ when(scheduleConfig.getTimezone()).thenReturn(TEST_TIMEZONE.toString());
+
+ // Initialize the scheduler with disabled configuration
+ scheduler = new ClusterScheduler(backendManager, scheduleConfig);
+ scheduler.start();
+
+ // Time within the cron schedule (9 AM - 5 PM)
+ ZonedDateTime testTime = ZonedDateTime.of(2025, 9, 29, 10, 0, 0, 0, TEST_TIMEZONE);
+
+ // Execute
+ scheduler.checkAndUpdateClusterStatus(testTime);
+
+ // Verify no actions are taken when scheduler is disabled
+ verify(backendManager, never()).activateBackend(anyString());
+ verify(backendManager, never()).deactivateBackend(anyString());
+ verify(backendManager, never()).getBackendByName(anyString());
+ }
+
+ @Test
+ void testSchedulerWithDifferentTimezones()
+ {
+ // Set up timezone to New York
+ ZoneId newYorkTime = ZoneId.of("America/New_York");
+ // Setup test cluster with activeDuringCron = true
+ when(scheduleConfig.isEnabled()).thenReturn(true);
+ when(scheduleConfig.getCheckInterval()).thenReturn(new Duration(1, TimeUnit.MINUTES));
+ when(scheduleConfig.getTimezone()).thenReturn(newYorkTime.toString());
+ when(scheduleConfig.getSchedules()).thenReturn(java.util.List.of(clusterSchedule));
+ when(clusterSchedule.getClusterName()).thenReturn(CLUSTER_NAME);
+ when(clusterSchedule.getCronExpression()).thenReturn(CRON_EXPRESSION);
+ when(clusterSchedule.isActiveDuringCron()).thenReturn(true);
+ when(backendManager.getBackendByName(CLUSTER_NAME)).thenReturn(java.util.Optional.of(backendConfig));
+
+ // Initialize scheduler with the new timezone
+ scheduler = new ClusterScheduler(backendManager, scheduleConfig);
+
+ // Initialize the scheduler
+ scheduler.start();
+
+ // Time within the cron schedule (9 AM - 5 PM) in New York time
+ ZonedDateTime testTime = ZonedDateTime.of(2025, 9, 29, 10, 0, 0, 0, newYorkTime);
+ when(backendConfig.isActive()).thenReturn(false);
+
+ // Execute
+ scheduler.checkAndUpdateClusterStatus(testTime);
+
+ // Verify
+ verify(backendManager, atLeastOnce()).activateBackend(CLUSTER_NAME);
+ verify(backendManager, atLeastOnce()).getBackendByName(CLUSTER_NAME);
+ verify(backendConfig, atLeastOnce()).isActive();
+ verify(scheduleConfig, atLeastOnce()).getTimezone();
+ }
+
+ @Test
+ void testInvalidCronExpression()
+ {
+ // Setup test cluster with an invalid cron expression (valid format but invalid values)
+ when(scheduleConfig.isEnabled()).thenReturn(true);
+ when(scheduleConfig.getCheckInterval()).thenReturn(new Duration(1, TimeUnit.MINUTES));
+ when(scheduleConfig.getTimezone()).thenReturn(TEST_TIMEZONE.toString());
+ when(scheduleConfig.getSchedules()).thenReturn(java.util.List.of(clusterSchedule));
+ when(clusterSchedule.getClusterName()).thenReturn(CLUSTER_NAME);
+ // Using a cron expression with invalid values that will fail validation
+ when(clusterSchedule.getCronExpression()).thenReturn("99 25 32 13 8"); // Invalid: minute=99, hour=25, day=32, month=13, day-of-week=8
+
+ // Initialize scheduler with the test configuration
+ scheduler = new ClusterScheduler(backendManager, scheduleConfig);
+
+ // Initialize the scheduler - this should log an error but not throw
+ assertThatNoException().isThrownBy(() -> scheduler.start());
+
+ // Verify the error was logged (you might want to add a test logger to verify this)
+ // For now, we'll just verify the mocks were called as expected
+ verify(scheduleConfig).getSchedules();
+ verify(clusterSchedule).getClusterName();
+ verify(clusterSchedule, atLeastOnce()).getCronExpression();
+
+ // Verify no action taken due to invalid cron expression
+ verify(backendManager, never()).activateBackend(anyString());
+ verify(backendManager, never()).deactivateBackend(anyString());
+ }
+
+ @Test
+ void testSchedulerWithMultipleClusters()
+ {
+ // Setup first cluster with activeDuringCron = true
+ when(clusterSchedule.getClusterName()).thenReturn(CLUSTER_NAME);
+ when(clusterSchedule.getCronExpression()).thenReturn(CRON_EXPRESSION);
+ when(clusterSchedule.isActiveDuringCron()).thenReturn(true);
+
+ // Setup second cluster with activeDuringCron = false
+ ScheduleConfiguration.ClusterSchedule secondCluster = mock(ScheduleConfiguration.ClusterSchedule.class);
+ String secondClusterName = "another-cluster";
+ when(secondCluster.getClusterName()).thenReturn(secondClusterName);
+ when(secondCluster.getCronExpression()).thenReturn(CRON_EXPRESSION);
+ when(secondCluster.isActiveDuringCron()).thenReturn(false);
+
+ // Setup backend configs
+ ProxyBackendConfiguration secondBackendConfig = mock(ProxyBackendConfiguration.class);
+ when(backendManager.getBackendByName(CLUSTER_NAME)).thenReturn(java.util.Optional.of(backendConfig));
+ when(backendManager.getBackendByName(secondClusterName)).thenReturn(java.util.Optional.of(secondBackendConfig));
+
+ // Setup schedules and config
+ when(scheduleConfig.isEnabled()).thenReturn(true);
+ when(scheduleConfig.getCheckInterval()).thenReturn(new Duration(1, TimeUnit.MINUTES));
+ when(scheduleConfig.getTimezone()).thenReturn(TEST_TIMEZONE.toString());
+ when(scheduleConfig.getSchedules()).thenReturn(java.util.List.of(clusterSchedule, secondCluster));
+
+ // Initialize scheduler with the test configuration
+ scheduler = new ClusterScheduler(backendManager, scheduleConfig);
+
+ // Initialize the scheduler
+ scheduler.start();
+
+ // Time within the cron schedule (9 AM - 5 PM)
+ ZonedDateTime testTime = ZonedDateTime.of(2025, 9, 29, 10, 0, 0, 0, TEST_TIMEZONE);
+ when(backendConfig.isActive()).thenReturn(false);
+ when(secondBackendConfig.isActive()).thenReturn(true);
+
+ // Execute
+ scheduler.checkAndUpdateClusterStatus(testTime);
+
+ // Verify first cluster is activated
+ verify(backendManager, atLeastOnce()).activateBackend(CLUSTER_NAME);
+ verify(backendManager, atLeastOnce()).getBackendByName(CLUSTER_NAME);
+ verify(backendConfig, atLeastOnce()).isActive();
+
+ // Verify second cluster is deactivated
+ verify(backendManager, atLeastOnce()).deactivateBackend(secondClusterName);
+ verify(backendManager, atLeastOnce()).getBackendByName(secondClusterName);
+ verify(secondBackendConfig, atLeastOnce()).isActive();
+ }
+
+ @Test
+ void testSchedulerWithInvertedActiveLogic()
+ {
+ // Setup test with activeDuringCron = false (inverted logic)
+ when(scheduleConfig.isEnabled()).thenReturn(true);
+ when(scheduleConfig.getCheckInterval()).thenReturn(new Duration(1, TimeUnit.MINUTES));
+ when(scheduleConfig.getTimezone()).thenReturn(TEST_TIMEZONE.toString());
+ when(scheduleConfig.getSchedules()).thenReturn(java.util.List.of(clusterSchedule));
+ when(clusterSchedule.getClusterName()).thenReturn(CLUSTER_NAME);
+ when(clusterSchedule.getCronExpression()).thenReturn(CRON_EXPRESSION);
+ when(clusterSchedule.isActiveDuringCron()).thenReturn(false);
+ when(backendManager.getBackendByName(CLUSTER_NAME)).thenReturn(Optional.of(backendConfig));
+
+ // Initialize the scheduler
+ scheduler = new ClusterScheduler(backendManager, scheduleConfig);
+ scheduler.start();
+
+ // Test 1: During cron time (10 AM) - should be INACTIVE due to inverted logic
+ ZonedDateTime activeTime = ZonedDateTime.of(2025, 9, 29, 10, 0, 0, 0, TEST_TIMEZONE);
+ when(backendConfig.isActive()).thenReturn(true);
+
+ // Execute
+ scheduler.checkAndUpdateClusterStatus(activeTime);
+
+ // Verify cluster is deactivated when cron matches (inverted logic)
+ verify(backendManager, atLeastOnce()).deactivateBackend(CLUSTER_NAME);
+ verify(backendManager, atLeastOnce()).getBackendByName(CLUSTER_NAME);
+ verify(backendConfig, atLeastOnce()).isActive();
+
+ // Reset mocks for second test
+ reset(backendManager, backendConfig);
+
+ // Re-setup mocks for second test
+ when(backendManager.getBackendByName(CLUSTER_NAME)).thenReturn(Optional.of(backendConfig));
+ when(backendConfig.isActive()).thenReturn(false);
+
+ // Test 2: Outside cron time (after 5 PM) - should be ACTIVE due to inverted logic
+ ZonedDateTime inactiveTime = ZonedDateTime.of(2025, 9, 29, 18, 0, 0, 0, TEST_TIMEZONE);
+
+ // Execute
+ scheduler.checkAndUpdateClusterStatus(inactiveTime);
+
+ // Verify cluster is activated (inverted logic: active when cron doesn't match)
+ verify(backendManager).activateBackend(CLUSTER_NAME);
+ verify(backendManager, atLeastOnce()).getBackendByName(CLUSTER_NAME);
+ verify(backendConfig).isActive();
+ }
+}