This is a production‑ready Spring Boot project that runs a Quartz job every 15 minutes on three pods/instances in PCF, ensuring only one instance executes the job at a time via Quartz JDBC clustering and row‑level locks. If a run might last up to 15 minutes, we configure misfire handling to avoid catch‑up storms.
✅ Works with PostgreSQL (recommended). Swap the Flyway DDL for your DB if different.
.
├─ pom.xml
├─ manifest.yml # PCF (Tanzu) deployment: 3 instances
├─ src
│ ├─ main
│ │ ├─ java/com/example/quartzpcf
│ │ │ ├─ QuartzPcfApplication.java
│ │ │ ├─ job/SampleClusteredJob.java
│ │ │ ├─ config/QuartzConfig.java
│ │ │ └─ config/AutowiringSpringBeanJobFactory.java
│ │ ├─ resources
│ │ │ ├─ application.yml
│ │ │ └─ logback-spring.xml
│ └─ test
│ └─ java/com/example/quartzpcf/QuartzSmokeTest.java
└─ src/main/resources/db/migration/V1__quartz_postgres.sql # Quartz tables via Flyway
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>quartz-pcf</artifactId>
<version>1.0.0</version>
<name>Spring Boot Quartz PCF</name>
<properties>
<java.version>17</java.version>
<spring-boot.version>3.3.4</spring-boot.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Core -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Quartz + JDBC store for clustering -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-quartz</artifactId>
</dependency>
<!-- JDBC + HikariCP connection pool -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- Flyway for schema management -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<!-- Observability -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<image>
<builder>paketobuildpacks/builder-jammy-base</builder>
</image>
</configuration>
</plugin>
</plugins>
</build>
</project>package com.example.quartzpcf;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class QuartzPcfApplication {
public static void main(String[] args) {
SpringApplication.run(QuartzPcfApplication.class, args);
}
}package com.example.quartzpcf.job;
import org.quartz.DisallowConcurrentExecution;
import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@DisallowConcurrentExecution // prevents overlap of same JobDetail on a single node
@Component
public class SampleClusteredJob implements Job {
private static final Logger log = LoggerFactory.getLogger(SampleClusteredJob.class);
@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
String fireInstanceId = context.getFireInstanceId();
String schedulerInstanceId = context.getScheduler().getSchedulerInstanceId();
log.info("[START] SampleClusteredJob fireInstanceId={} schedulerInstanceId={}", fireInstanceId, schedulerInstanceId);
long start = System.currentTimeMillis();
try {
// TODO: Place your real work here. Ensure it completes in ≤ 15 minutes.
// Simulate work
Thread.sleep(10_000L);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new JobExecutionException("Job interrupted", ie);
} catch (Exception e) {
log.error("Job failed", e);
throw new JobExecutionException(e);
} finally {
long tookMs = System.currentTimeMillis() - start;
log.info("[END] SampleClusteredJob completed in {} ms", tookMs);
}
}
}package com.example.quartzpcf.config;
import org.quartz.spi.TriggerFiredBundle;
import org.springframework.beans.factory.config.AutowireCapableBeanFactory;
import org.springframework.scheduling.quartz.AdaptableJobFactory;
public class AutowiringSpringBeanJobFactory extends AdaptableJobFactory {
private final AutowireCapableBeanFactory beanFactory;
public AutowiringSpringBeanJobFactory(AutowireCapableBeanFactory beanFactory) {
this.beanFactory = beanFactory;
}
@Override
protected Object createJobInstance(TriggerFiredBundle bundle) throws Exception {
Object job = super.createJobInstance(bundle);
beanFactory.autowireBean(job);
return job;
}
}package com.example.quartzpcf.config;
import com.example.quartzpcf.job.SampleClusteredJob;
import org.quartz.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.config.AutowireCapableBeanFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.quartz.SchedulerFactoryBean;
import javax.sql.DataSource;
import java.util.Properties;
@Configuration
public class QuartzConfig {
@Bean
public JobDetail clusteredJobDetail() {
return JobBuilder.newJob(SampleClusteredJob.class)
.withIdentity("sampleClusteredJob")
.storeDurably(true) // Keep job even if no triggers
.build();
}
@Bean
public Trigger fifteenMinuteTrigger(
@Value("${app.schedule.cron:0 0/15 * * * ?}") String cron,
JobDetail clusteredJobDetail) {
return TriggerBuilder.newTrigger()
.forJob(clusteredJobDetail)
.withIdentity("sampleClusteredJob.trigger")
.withSchedule(CronScheduleBuilder.cronSchedule(cron)
// If a trigger is missed while node was down or job was running, skip catch-up
.withMisfireHandlingInstructionDoNothing())
.build();
}
@Bean
public SchedulerFactoryBean schedulerFactoryBean(
DataSource dataSource,
AutowireCapableBeanFactory beanFactory,
@Value("${spring.application.name:quartz-pcf}") String appName,
@Value("${app.quartz.threadCount:5}") int threadCount,
@Value("${app.quartz.clusterCheckinMs:10000}") long clusterCheckinMs,
JobDetail clusteredJobDetail,
Trigger fifteenMinuteTrigger) {
Properties props = new Properties();
// Scheduler identity
props.setProperty("org.quartz.scheduler.instanceName", appName + "-scheduler");
props.setProperty("org.quartz.scheduler.instanceId", "AUTO");
// ThreadPool
props.setProperty("org.quartz.threadPool.class", "org.quartz.simpl.SimpleThreadPool");
props.setProperty("org.quartz.threadPool.threadCount", String.valueOf(threadCount));
props.setProperty("org.quartz.threadPool.threadPriority", "5");
// JobStore: JDBC clustered store with row-level locks
props.setProperty("org.quartz.jobStore.class", "org.quartz.impl.jdbcjobstore.JobStoreTX");
props.setProperty("org.quartz.jobStore.driverDelegateClass", "org.quartz.impl.jdbcjobstore.PostgreSQLDelegate");
props.setProperty("org.quartz.jobStore.tablePrefix", "QRTZ_");
props.setProperty("org.quartz.jobStore.isClustered", "true");
props.setProperty("org.quartz.jobStore.clusterCheckinInterval", String.valueOf(clusterCheckinMs));
props.setProperty("org.quartz.jobStore.misfireThreshold", "60000");
// Use DB-based locks (default). For high-contention, consider SelectWithLock on delegate.
SchedulerFactoryBean factory = new SchedulerFactoryBean();
factory.setDataSource(dataSource);
factory.setQuartzProperties(props);
factory.setJobFactory(new AutowiringSpringBeanJobFactory(beanFactory));
factory.setSchedulerName(appName + "-scheduler");
factory.setAutoStartup(true);
factory.setStartupDelay(10); // allow DB to be ready
factory.setOverwriteExistingJobs(true);
factory.setWaitForJobsToCompleteOnShutdown(true);
factory.setJobDetails(clusteredJobDetail);
factory.setTriggers(fifteenMinuteTrigger);
return factory;
}
}server:
port: ${PORT:8080} # PCF will pass PORT
spring:
application:
name: quartz-pcf
datasource:
# Supply via env or PCF service binding (VCAP_SERVICES). Example env-based:
url: ${JDBC_URL:jdbc:postgresql://localhost:5432/quartz}
username: ${JDBC_USERNAME:quartz}
password: ${JDBC_PASSWORD:quartz}
hikari:
maximum-pool-size: 10
minimum-idle: 2
connection-timeout: 30000
validation-timeout: 5000
leak-detection-threshold: 60000
quartz:
job-store-type: jdbc # ensures Spring creates Quartz tables if using its initializer; we use Flyway instead
jdbc:
initialize-schema: never # Flyway manages it
management:
endpoints:
web:
exposure:
include: health,info,metrics,loggers,env
endpoint:
health:
probes:
enabled: true
show-details: when_authorized
health:
db:
enabled: true
quartz:
enabled: true
# App tuning
app:
schedule:
cron: "0 0/15 * * * ?" # every 15 minutes on the clock
quartz:
threadCount: 5
clusterCheckinMs: 10000<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<springProperty scope="context" name="APP_NAME" source="spring.application.name" defaultValue="quartz-pcf"/>
<property name="CONSOLE_PATTERN" value="%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX} %-5level [%thread] %logger{36} - %msg%n"/>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${CONSOLE_PATTERN}</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
</configuration>Quartz’s official schema for PostgreSQL (prefix
QRTZ_). If you already manage schemas elsewhere, omit Flyway and ensure tables exist.
-- Minimal excerpt — use official Quartz 2.3.x PostgreSQL DDL; keep prefix QRTZ_
-- You can copy from: org/quartz/impl/jdbcjobstore/tables_postgres.sql in Quartz distro.
-- Example beginnings:
CREATE TABLE QRTZ_JOB_DETAILS (
SCHED_NAME VARCHAR(120) NOT NULL,
JOB_NAME VARCHAR(200) NOT NULL,
JOB_GROUP VARCHAR(200) NOT NULL,
DESCRIPTION VARCHAR(250) NULL,
JOB_CLASS_NAME VARCHAR(250) NOT NULL,
IS_DURABLE BOOL NOT NULL,
IS_NONCONCURRENT BOOL NOT NULL,
IS_UPDATE_DATA BOOL NOT NULL,
REQUESTS_RECOVERY BOOL NOT NULL,
JOB_DATA BYTEA NULL,
PRIMARY KEY (SCHED_NAME,JOB_NAME,JOB_GROUP)
);
-- ... (include full Quartz schema here)
⚠️ Important: Use the full schema from Quartz 2.3.x for PostgreSQL—don’t deploy with the abbreviated sample above. Replace this file with the full DDL before first deploy.
applications:
- name: quartz-pcf
memory: 1024M
instances: 3 # three pods/instances
path: target/quartz-pcf-1.0.0.jar
buildpacks:
- java_buildpack
env:
JBP_CONFIG_JAVA_MAIN: '{ "arguments": "" }'
JAVA_OPTS: "-Djava.security.egd=file:/dev/./urandom"
JDBC_URL: "jdbc:postgresql://YOUR_PG_HOST:5432/quartz"
JDBC_USERNAME: "quartz"
JDBC_PASSWORD: "quartz"
services:
- your-postgres-service # if using CF service binding (VCAP_SERVICES)package com.example.quartzpcf;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class QuartzSmokeTest {
@Test
void contextLoads() { }
}- Quartz JDBC Clustering: All 3 instances share the same database tables. The trigger is acquired via database locks, so only one node fires the job each schedule. If a node dies mid‑run, another node takes over at the next firing.
@DisallowConcurrentExecution: Prevents parallel runs of the sameJobDetailon a single node (e.g., if a previous run overlaps).- Misfires:
withMisfireHandlingInstructionDoNothing()avoids a backlog of missed firings if an instance was down or the job took a bit longer. - Graceful shutdown:
waitForJobsToCompleteOnShutdown=trueensures the pod tries to finish the current run before exiting.
If you need absolutely no overlap when runtime ≈ schedule (15 min), you’re covered. If runtime sometimes exceeds 15 minutes, Quartz will skip the next fire due to misfire policy instead of queuing catch‑ups.
- DB readiness: The scheduler starts after a short delay (
startupDelay: 10) to allow DB connectivity. - Observability: Exposes
/actuator/health,/actuator/metrics, etc. PCF can use these for health checks. - Tuning: Adjust
app.quartz.threadCountbased on job complexity. For a single job every 15 minutes, 5 threads is plenty. - Idempotency: Make the job’s side effects idempotent (e.g., use transactional upserts) in case of rare failover edge cases.
- Time zone: The cron uses instance default TZ. Pin a TZ at container level if you have strict wall‑clock expectations.
# 1) Start local Postgres, create DB quartz, user/pass quartz/quartz
# 2) Put the full Quartz DDL into V1__quartz_postgres.sql
mvn -q -DskipTests package
java -jar target/quartz-pcf-1.0.0.jarIn QuartzConfig, change driverDelegateClass and Flyway DDL to match your DB:
- PostgreSQL:
org.quartz.impl.jdbcjobstore.PostgreSQLDelegate✅ (default here) - MySQL:
org.quartz.impl.jdbcjobstore.StdJDBCDelegate - Oracle:
org.quartz.impl.jdbcjobstore.oracle.OracleDelegate
- Use managed DB credentials (PCF service binding / Vault). Avoid plaintext in
manifest.yml. - Configure connection pool limits per platform guidance.
- Add retry/backoff around your job’s external calls.
- Add structured logging correlation IDs if calling other services.
- Add metrics inside the job (timer, success/failure counters) via Micrometer.
- Add dead‑man’s switch alert if no successful run in > 20 minutes.
Q: “Only one instance of the Scheduler should be running at any given time” — does this mean only one Quartz scheduler bean?
A: In a Quartz cluster, each node has a scheduler but only one acquires the trigger at a given fire time. That’s the standard, reliable pattern for distributed single‑execution. If you truly want only one pod to have Quartz at all, scale instances: 1 or gate scheduling behind a separate leader‑elected sidecar — but you lose HA.
Q: What if a run sometimes exceeds 15 minutes?
A: Keep DoNothing misfire policy so the next run is skipped if still busy. If you need strict “start exactly every 15 min regardless”, remove that misfire override and ensure your job is re‑entrant — not typical for this requirement.
- Replace the Flyway SQL with the full Quartz schema for your DB.
- Wire your real logic in
SampleClusteredJoband make it idempotent & observable. - Deploy with
manifest.ymland bind a managed Postgres service in PCF.
I put a full, production-ready starter in the canvas: clustered Quartz on Spring Boot, set to run every 15 minutes, safe for 3 PCF instances so only one executes at a time. It includes:
- Maven
pom.xml, Actuator, Flyway, and JDBC-backed Quartz clustering - A
@DisallowConcurrentExecutionjob, cron trigger (0 0/15 * * * ?), and “do nothing on misfire” - Robust
SchedulerFactoryBeanwith JDBCJobStoreTX,PostgreSQLDelegate, cluster settings, and graceful shutdown application.yml,logback-spring.xml, PCFmanifest.yml(3 instances), and a Flyway migration placeholder for the Quartz tables- Notes on HA behavior, ops hardening, and next steps
If you want, I can also drop in:
- Full Quartz Postgres DDL (or MySQL/Oracle variant)
- Micrometer timers/counters around the job
- A
HealthIndicatorthat verifies the scheduler is started and DB-locked properly - A simple REST endpoint to kick the job on-demand for testing