Skip to content

Scheduled and deferred jobs

View Markdown

Vertique’s job modules run background work when it should run, either recurring on a cron schedule or deferred to a future time. Retry, dead-letter handling, and cluster-aware coordination are built in, and jobs run in memory or durably on PostgreSQL.

  • Cron scheduling from an annotation. Mark a service method with @CronJob, or register a config-only entry with no code. Expressions have six fields with seconds precision and run in the job’s configured timezone.
  • Run once per cluster. SINGLE_INSTANCE mode elects exactly one node per fire time through a database unique-index race, without separate leader-election infrastructure.
  • Misfire recovery. Fires missed while every node was down are recovered by policy: fire the most recent, skip them all, or fire each in order.
  • A durable deferred queue. A delayed job runs once at or after a future time and survives restarts. It can be enqueued inside your business transaction, where it commits or rolls back with the write it accompanies.
  • Retry and dead-letter built in. Fixed, linear, or exponential backoff applies up to a maximum attempt count. After that the job moves to a dead-letter state and is not silently lost.
  • Typed job contracts. A pair of linked interfaces separates the enqueue API from the execution logic, with generated proxies keeping enqueueing compile-time typed.
  • Cluster-aware coordination. The job coordinator tracks node heartbeats and recovers orphaned executions across the cluster.

Reach for a job when the work is a single handler invocation on a schedule or after a delay. A multi-step instance that waits on a signal or a human decision is a durable workflow, and handing a transactional side effect to another system is inbox/outbox messaging. A job handler can still use inbox/outbox internally.

A typed job contract separates the enqueue API from the execution logic using a pair of linked interfaces. From the public vertique-example-webhook example:

@DelayedJobContract(name = "deliver-webhook", maxAttempts = 2)
public interface DeliverWebhookJob extends DelayedJobClient<WebhookPayload> {}
@Singleton
public class DeliverWebhookJobImpl implements DelayedJobExecutor<WebhookPayload, DeliverWebhookJob> {
private final WebhookClient webhookClient;
@Inject
DeliverWebhookJobImpl(WebhookClient webhookClient) {
this.webhookClient = webhookClient;
}
@Override
public Future<Void> execute(WebhookPayload payload, JobContext ctx) {
ctx.logger().info("Delivering webhook for payment " + payload.paymentId());
return webhookClient.notify(payload.paymentId());
}
}

To call it, inject the contract interface anywhere and enqueue with a typed payload: immediately, at a time, after a delay, or atomically inside an existing database transaction.

job.enqueue(new WebhookPayload("pay_123")); // run as soon as possible
job.enqueue(payload, Duration.ofMinutes(5)); // run five minutes from now
job.enqueue(payload, runAt); // run at a specific instant
job.enqueue(payload, tx); // commit or roll back with the business transaction

@DelayedJobContract names the contract and its default enqueue parameters (maxAttempts, queue, priority). The executor is discovered at startup from its own generic type arguments, so it needs no separate registration call. The vertique-job-delayed module reference documents the wiring, generated proxies, and runtime overrides in full.

Mark a service implementation method with @CronJob to register a recurring job. From the vertique-job-cron module reference’s example:

public class CleanupServiceImpl implements CleanupService {
@CronJob(id = "cleanup-expired", cron = "0 0 2 * * *", overlapPolicy = OverlapPolicy.SKIP)
@Override
public Future<Void> cleanupExpired() {
return repository.deleteExpiredRecords();
}
@CronJob(
id = "daily-report",
cron = "0 0 8 * * *",
mode = ExecutionMode.SINGLE_INSTANCE,
timezone = "Europe/Helsinki",
tracked = "true",
misfirePolicy = MisfirePolicy.FIRE_NOW
)
@Override
public Future<Void> generateDailyReport() {
return reportService.generate();
}
}

Cron expressions use a 6-field format with seconds precision: 0 0 8 * * * fires daily at 08:00 in the job’s timezone (UTC by default). SINGLE_INSTANCE runs a job on exactly one node per cluster per fire time (it requires vertique-job-postgresql), and the misfire policy decides what happens to fires missed while every node was down. A config-only job is a named entry under the cron.jobs config subtree with no matching annotation. It registers the same way and targets a service or an event bus address. The vertique-job-cron module reference documents the expression format, execution modes, overlap policies, and misfire recovery in full.

vertique-job-delayed is a persistent, database-backed queue. Enqueued work survives restarts and becomes eligible at its scheduled time. It is dispatched over the event bus to its handler with the configured backoff (fixed, linear, or exponential) up to a maximum attempt count before moving to a dead-letter state. Enqueueing inside an existing transaction commits or rolls back with the business write it accompanies. The vertique-job-delayed module reference documents the claim mechanics, retry configuration, and outcome reporting in full.

vertique-job-core defines the shared job state machine, the JobContext API a handler uses to report progress and check cancellation, the JobRepository and JobInterceptor SPIs, and JobCoordinator, which tracks node heartbeats and recovers orphaned executions across a cluster. Two triggers build on it: vertique-job-cron schedules recurring executions, and vertique-job-delayed is the persistent deferred queue.

Add For
vertique-job-core The state machine, JobContext, and the coordinator every job scheduling module shares
vertique-job-cron Recurring executions from @CronJob methods or config; runs in memory unless paired with vertique-job-postgresql
vertique-job-delayed Deferred, durable execution with retry and dead-letter; always requires vertique-job-postgresql
vertique-job-postgresql The JobRepository implementation backing durable cron scheduling and every delayed job

vertique-job-cron can run without persistence: install vertique-job-core and vertique-job-cron alone, and cron scheduling stays in memory on each node. vertique-job-delayed has no such in-memory mode. It depends directly on vertique-job-postgresql, so a delayed job’s state is always durable and visible across the cluster once it is enqueued. There is no vertique-starter-job. An application composes these artifacts directly into its own Dagger component.

vertique-job-postgresql wires the database connection layer directly and does not go through a starter. It depends on vertique-db-postgresql, and the application still installs both DbPostgresqlModule and DbFlywayModule in its own Dagger component. The adapter does not run Flyway migration for the application, but it ships its migration scripts at classpath:db/migration/job and a JobMigrationsModule that declares them as a migration named job. With the opt-in named mode (flyway.instances), that declaration gives the job tables their own Flyway history, and an optional db.schemas.job setting places them in their own schema for both the migration and the repository’s SQL. In the default single-history mode, flyway.locations must instead list the job path alongside the application’s own migration location for the scripts to run. An application that already uses the PostgreSQL starter for its own repositories (see Persistence) keeps that starter and composes the job PostgreSQL adapter alongside it. The module’s own reference documents the wiring. See Durable workflows for the same pattern stated for the workflow and inbox/outbox adapters.

The module references below document the constructs, SQL, configuration, and extension points in full. This page does not repeat them.