Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 

Repository files navigation

Spring Boot + Quartz (Clustered) on PCF — Production‑Ready Starter

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.


Project Layout

.
├─ 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

pom.xml

<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>

QuartzPcfApplication.java

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);
  }
}

job/SampleClusteredJob.java

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);
    }
  }
}

config/AutowiringSpringBeanJobFactory.java

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;
  }
}

config/QuartzConfig.java

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;
  }
}

application.yml

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

logback-spring.xml

<?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>

Flyway migration: V1__quartz_postgres.sql

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.


manifest.yml (PCF / Tanzu)

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)

QuartzSmokeTest.java

package com.example.quartzpcf;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
class QuartzSmokeTest {
  @Test
  void contextLoads() { }
}

Why this guarantees single execution across 3 pods

  • 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 same JobDetail on 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=true ensures 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.


Operational Notes

  • 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.threadCount based 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.

Local Run

# 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.jar

Swap DB Vendors

In 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

Hardening Checklist

  • 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.

FAQ

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.


Next Steps

  • Replace the Flyway SQL with the full Quartz schema for your DB.
  • Wire your real logic in SampleClusteredJob and make it idempotent & observable.
  • Deploy with manifest.yml and 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 @DisallowConcurrentExecution job, cron trigger (0 0/15 * * * ?), and “do nothing on misfire”
  • Robust SchedulerFactoryBean with JDBC JobStoreTX, PostgreSQLDelegate, cluster settings, and graceful shutdown
  • application.yml, logback-spring.xml, PCF manifest.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 HealthIndicator that verifies the scheduler is started and DB-locked properly
  • A simple REST endpoint to kick the job on-demand for testing

About

quartz-pcf

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages