diff --git a/quartz/build.gradle b/quartz/build.gradle index 56b07b416..c139c00ce 100644 --- a/quartz/build.gradle +++ b/quartz/build.gradle @@ -42,6 +42,8 @@ dependencies { testImplementation 'org.junit.jupiter:junit-jupiter-params' testImplementation 'org.junit.jupiter:junit-jupiter-params' testImplementation 'org.mockito:mockito-core:5.14.2' + testImplementation 'org.testcontainers:mssqlserver:1.20.3' + testImplementation 'com.microsoft.sqlserver:mssql-jdbc:12.8.1.jre11' testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") } diff --git a/quartz/src/main/java/org/quartz/impl/jdbcjobstore/MSSQLDelegate.java b/quartz/src/main/java/org/quartz/impl/jdbcjobstore/MSSQLDelegate.java index bbbbbf9ec..70bead214 100644 --- a/quartz/src/main/java/org/quartz/impl/jdbcjobstore/MSSQLDelegate.java +++ b/quartz/src/main/java/org/quartz/impl/jdbcjobstore/MSSQLDelegate.java @@ -18,11 +18,20 @@ package org.quartz.impl.jdbcjobstore; +import static org.quartz.TriggerKey.triggerKey; + import java.io.IOException; import java.io.InputStream; import java.io.ObjectInputStream; +import java.math.BigDecimal; +import java.sql.Connection; +import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.util.LinkedList; +import java.util.List; + +import org.quartz.TriggerKey; /** *

@@ -80,6 +89,44 @@ protected Object getJobDataFromBlob(ResultSet rs, String colName) } return getObjectFromBlob(rs, colName); } + + @Override + public List selectTriggerToAcquire(Connection conn, long noLaterThan, long noEarlierThan, int maxCount) + throws SQLException { + // Set max rows to retrieve + if (maxCount < 1) + maxCount = 1; // we want at least one trigger back. + String selectTriggerToAcquire = "SELECT TOP " + maxCount + " " + SELECT_NEXT_TRIGGER_TO_ACQUIRE.substring(6); + PreparedStatement ps = null; + ResultSet rs = null; + List nextTriggers = new LinkedList<>(); + try { + ps = conn.prepareStatement(rtp(selectTriggerToAcquire)); + + ps.setMaxRows(maxCount); + + // Try to give jdbc driver a hint to hopefully not pull over more than the few rows we actually need. + // Note: in some jdbc drivers, such as MySQL, you must set maxRows before fetchSize, or you get exception! + ps.setFetchSize(maxCount); + + ps.setString(1, STATE_WAITING); + ps.setBigDecimal(2, new BigDecimal(String.valueOf(noLaterThan))); + ps.setBigDecimal(3, new BigDecimal(String.valueOf(noEarlierThan))); + rs = ps.executeQuery(); + + while (rs.next() && nextTriggers.size() < maxCount) { + nextTriggers.add(triggerKey( + rs.getString(COL_TRIGGER_NAME), + rs.getString(COL_TRIGGER_GROUP))); + } + + return nextTriggers; + } finally { + closeResultSet(rs); + closeStatement(ps); + } + } + } // EOF diff --git a/quartz/src/test/java/org/quartz/impl/jdbcjobstore/JdbcQuartzMSSQLUtilities.java b/quartz/src/test/java/org/quartz/impl/jdbcjobstore/JdbcQuartzMSSQLUtilities.java new file mode 100644 index 000000000..23a696d99 --- /dev/null +++ b/quartz/src/test/java/org/quartz/impl/jdbcjobstore/JdbcQuartzMSSQLUtilities.java @@ -0,0 +1,82 @@ +/* + * Copyright Super iPaaS Integration LLC, an IBM Company 2024 + * + * 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 org.quartz.impl.jdbcjobstore; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import org.testcontainers.containers.MSSQLServerContainer; + +/** + * A utility class to create a database for Quartz MSSQL test. + * + * @author Arnaud Mergey + */ + +public final class JdbcQuartzMSSQLUtilities { + private static final List DATABASE_SETUP_STATEMENTS; + static { + List setup = new ArrayList(); + String setupScript; + try { + InputStream setupStream = MSSQLDelegate.class.getClassLoader() + .getResourceAsStream("org/quartz/impl/jdbcjobstore/tables_sqlServer.sql"); + try { + BufferedReader r = new BufferedReader(new InputStreamReader(setupStream, "US-ASCII")); + StringBuilder sb = new StringBuilder(); + while (true) { + String line = r.readLine(); + if (line == null) { + break; + } else if (!line.startsWith("--")) { + sb.append(line.replace("GO", ";").replace("[enter_db_name_here]", "[master]")).append("\n"); + } + } + setupScript = sb.toString(); + } finally { + setupStream.close(); + } + } catch (IOException e) { + throw new AssertionError(e); + } + for (String command : setupScript.split(";")) { + if (!command.matches("\\s*")) { + setup.add(command); + } + } + DATABASE_SETUP_STATEMENTS = setup; + } + + public static void createDatabase(MSSQLServerContainer container) throws SQLException { + Connection conn = container.createConnection(""); + try { + Statement statement = conn.createStatement(); + for (String command : DATABASE_SETUP_STATEMENTS) { + statement.addBatch(command); + } + statement.executeBatch(); + } finally { + conn.close(); + } + } +} \ No newline at end of file diff --git a/quartz/src/test/java/org/quartz/integrations/tests/QuartzMSSQLDatabaseCronTriggerTest.java b/quartz/src/test/java/org/quartz/integrations/tests/QuartzMSSQLDatabaseCronTriggerTest.java new file mode 100644 index 000000000..23350972c --- /dev/null +++ b/quartz/src/test/java/org/quartz/integrations/tests/QuartzMSSQLDatabaseCronTriggerTest.java @@ -0,0 +1,61 @@ +/* + * Copyright Super iPaaS Integration LLC, an IBM Company 2024 + * + * 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 org.quartz.integrations.tests; +import java.util.Collections; + +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +import org.quartz.*; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.collection.IsCollectionWithSize.hasSize; +import static org.hamcrest.core.Is.is; +import static org.hamcrest.number.OrderingComparison.greaterThanOrEqualTo; +import static org.quartz.integrations.tests.TrackingJob.SCHEDULED_TIMES_KEY; +/** + * A integration test for Quartz MSSQL Database Scheduler with Cron Trigger. + * + * @author Arnaud Mergey + */ +public class QuartzMSSQLDatabaseCronTriggerTest extends QuartzMSSQLTestSupport { + @Test + void testCronRepeatCount() throws Exception { + CronTrigger trigger = TriggerBuilder.newTrigger() + .withIdentity("test") + .withSchedule(CronScheduleBuilder.cronSchedule("* * * * * ?")) + .build(); + List scheduledTimes = Collections.synchronizedList(new LinkedList()); + scheduler.getContext().put(SCHEDULED_TIMES_KEY, scheduledTimes); + JobDetail jobDetail = JobBuilder.newJob(TrackingJob.class).withIdentity("test").build(); + scheduler.scheduleJob(jobDetail, trigger); + + for (int i = 0; i < 20 && scheduledTimes.size() < 3; i++) { + Thread.sleep(500); + } + assertThat(scheduledTimes, hasSize(greaterThanOrEqualTo(3))); + + Long[] times = scheduledTimes.toArray(new Long[scheduledTimes.size()]); + long baseline = times[0]; + assertThat(baseline % 1000, is(0L)); + for (int i = 1; i < times.length; i++) { + assertThat(times[i], is(baseline + TimeUnit.SECONDS.toMillis(i))); + } + } +} \ No newline at end of file diff --git a/quartz/src/test/java/org/quartz/integrations/tests/QuartzMSSQLTestSupport.java b/quartz/src/test/java/org/quartz/integrations/tests/QuartzMSSQLTestSupport.java new file mode 100644 index 000000000..6f29cb552 --- /dev/null +++ b/quartz/src/test/java/org/quartz/integrations/tests/QuartzMSSQLTestSupport.java @@ -0,0 +1,86 @@ +/* + * Copyright Super iPaaS Integration LLC, an IBM Company 2024 + * + * 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 org.quartz.integrations.tests; + +import java.sql.SQLException; +import java.util.Properties; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.quartz.impl.jdbcjobstore.JdbcQuartzMSSQLUtilities; +import org.quartz.impl.jdbcjobstore.MSSQLDelegate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.MSSQLServerContainer; +import org.testcontainers.utility.DockerImageName; + +/** + * A base class to support database (MSSQL) scheduler integration testing. Each + * test will have a fresh scheduler created and started, and it will auto + * shutdown upon each test run. The database will be created with schema before + * class and destroy after class test. + * + * @author Arnaud Mergey + */ +public class QuartzMSSQLTestSupport extends QuartzMemoryTestSupport { + protected static final Logger LOG = LoggerFactory.getLogger(QuartzMSSQLTestSupport.class); + static MSSQLServerContainer mssqlserver = new MSSQLServerContainer( + DockerImageName.parse(MSSQLServerContainer.IMAGE).withTag("latest")).acceptLicense(); + + @BeforeAll + public static void initialize() throws Exception { + LOG.info("Starting MSSQL database."); + mssqlserver.start(); + LOG.info("Database started."); + try { + LOG.info("Creating Database tables for Quartz."); + JdbcQuartzMSSQLUtilities.createDatabase(mssqlserver); + LOG.info("Database tables created."); + } catch (SQLException e) { + throw new Exception("Failed to create Quartz tables.", e); + } + } + + @AfterAll + public static void shutdownDb() throws Exception { + mssqlserver.stop(); + LOG.info("Database shutdown."); + } + + protected Properties createSchedulerProperties() { + Properties properties = new Properties(); + properties.put("org.quartz.scheduler.instanceName", "TestScheduler"); + properties.put("org.quartz.scheduler.instanceId", "AUTO"); + properties.put("org.quartz.scheduler.skipUpdateCheck", "true"); + properties.put("org.quartz.threadPool.class", "org.quartz.simpl.SimpleThreadPool"); + properties.put("org.quartz.threadPool.threadCount", "12"); + properties.put("org.quartz.threadPool.threadPriority", "5"); + properties.put("org.quartz.jobStore.misfireThreshold", "10000"); + properties.put("org.quartz.jobStore.class", "org.quartz.impl.jdbcjobstore.JobStoreTX"); + properties.put("org.quartz.jobStore.driverDelegateClass", MSSQLDelegate.class.getName()); + properties.put("org.quartz.jobStore.useProperties", "true"); + properties.put("org.quartz.jobStore.dataSource", "myDS"); + properties.put("org.quartz.jobStore.tablePrefix", "QRTZ_"); + properties.put("org.quartz.jobStore.isClustered", "false"); + properties.put("org.quartz.dataSource.myDS.driver", mssqlserver.getDriverClassName()); + properties.put("org.quartz.dataSource.myDS.URL", mssqlserver.getJdbcUrl()); + properties.put("org.quartz.dataSource.myDS.user", mssqlserver.getUsername()); + properties.put("org.quartz.dataSource.myDS.password", mssqlserver.getPassword()); + properties.put("org.quartz.dataSource.myDS.maxConnections", "5"); + properties.put("org.quartz.dataSource.myDS.provider", "hikaricp"); + return properties; + } +} \ No newline at end of file