Skip to content

Status: Stable Package: dev.vertique.job.cron Artifact: vertique-job-cron Depends on: job-core, core, context, services, deploy, logging API reference: javadoc.io

Timer-based cron scheduler for recurring jobs. Parses 6-field cron expressions, registers self-rescheduling Vert.x timers, and dispatches executions via fire-and-report over the event bus to service operation handlers (the reply-address delivery mode documented in vertique-services’s module reference). Jobs are discovered at startup by scanning service implementations for @CronJob annotations. Config-only jobs (without annotations) can be registered via the cron.jobs config subtree. Supports both in-memory (CronModule) and DB-backed (CronPersistenceModule) operation. SINGLE_INSTANCE mode uses INSERT ON CONFLICT leader election for cluster-wide singletons. Misfire recovery fires executions missed while all nodes were down.


Method-level annotation that marks a service implementation method as a recurring cron job. Place on the implementation method, not the contract interface.

The contract method must carry @ServiceOperation. Cron resolves a job to a stable service target, and only an annotated operation has one — registration fails at startup otherwise.

@ServiceContract(namespace = "maintenance", value = "cleanup-service")
public interface CleanupService {
@ServiceOperation("cleanup-expired")
Future<Void> cleanupExpired();
@ServiceOperation("daily-report")
Future<Void> generateDailyReport();
}
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();
}
}
Attribute Required Default Description
id Yes — Unique job identifier (used as config override key)
cron Yes — Cron expression in 6-field format
mode No EVERY_INSTANCE Execution mode; SINGLE_INSTANCE requires a JobRepository
timezone No "UTC" IANA timezone ID for evaluating the cron expression
maxAttempts No 3 Max retry attempts before dead-letter
overlapPolicy No SKIP What to do when the previous execution is still running
tracked No "" (use global) "true", "false", or "" (use cron.tracked config; default true)
misfirePolicy No FIRE_NOW for SINGLE_INSTANCE, SKIP for EVERY_INSTANCE How to handle fires missed while the app was down. The annotation’s own default is FIRE_NOW; for EVERY_INSTANCE jobs, an unset (or explicit — the two are indistinguishable) FIRE_NOW is rewritten to SKIP at registration so the mode-based default applies. See EVERY_INSTANCE + FIRE_NOW rewrite below.

Cron expression format (6 fields):

second minute hour day-of-month month day-of-week
0 0 8 * * * → daily at 08:00 UTC
0 */15 * * * * → every 15 minutes
0 0 9 * * 1 → every Monday at 09:00
0/30 * * * * * → every 30 seconds
0 0 0 15 * * → 15th of each month at midnight

Supported syntax: * (all), N (specific), A-B (range), A,B,C (list), */N (every N from min), A-B/N (every N in range). Day-of-week: 0 = Sunday, 1 = Monday, …, 6 = Saturday (7 is also accepted as Sunday). For day matching, when both day-of-month and day-of-week are restricted, the job fires when either field matches (OR). If either field is a wildcard, the other field controls.

6-field cron expression parser that computes next fire times. Parses into a TreeSet<Integer> per field for fast lookup.

Method Description
CronExpression(String) Parse; throws IllegalArgumentException on invalid expression
computeNextFireTime(Instant from, ZoneId zone) Returns the next Instant strictly after from; searches up to 4 years
computeFireTimesBetween(Instant from, Instant to, ZoneId zone, int maxResults) Returns all fire instants in (from, to]; used for misfire detection
expression() Returns the raw expression string
Constant Description
EVERY_INSTANCE The job fires on every application instance independently; no distributed locking
SINGLE_INSTANCE Runs on exactly one node per cluster per fire time; uses INSERT ON CONFLICT leader election; requires JobRepository and SKIP overlap policy
Constant Description
SKIP Drop the fire if the previous execution is still running; logs a warning (default)
QUEUE_ONE Queue one missed fire to run immediately after the current execution; only the most recent skipped time is queued (earlier fires are replaced). Only supported with EVERY_INSTANCE.

Applied at startup during misfire recovery. Requires a JobRepository and a persisted schedule with last_fired_at data; jobs without a repository or with tracked=false always behave as SKIP.

Constant Description
FIRE_NOW Execute the most recent missed fire immediately (only one, even if multiple ticks were missed). Default for SINGLE_INSTANCE.
SKIP Ignore missed fires; wait for the next scheduled tick. Default for EVERY_INSTANCE.
FIRE_ALL Execute every missed fire in sequence, oldest to newest. Capped at CronScheduler.MAX_MISFIRE_FIRES (100).

EVERY_INSTANCE + FIRE_NOW rewrite. @CronJob.misfirePolicy() defaults to FIRE_NOW, so CronJobRegistrar cannot distinguish “user explicitly wrote FIRE_NOW” from “user left the default.” For an EVERY_INSTANCE job, an annotation value of FIRE_NOW (explicit or defaulted — indistinguishable) is therefore rewritten to SKIP before registration. This gives the intended UX — EVERY_INSTANCE defaults to SKIP, SINGLE_INSTANCE defaults to FIRE_NOW — but means an EVERY_INSTANCE job cannot select FIRE_NOW via the annotation; use the cron.jobs.<id>.misfirePolicy config override instead, which takes precedence and is applied verbatim.

Sealed interface representing where a cron job dispatches. Two variants:

Variant Scheme Description
ServiceTarget service:{stableServiceTargetId} Resolved via ServiceTargetResolver once per fire, so the persisted reference outlives any change to the transport address
EventBusTarget eventbus:{eventBusAddress} Dispatched directly to the supplied event bus address
CronTargetReference target = CronTargetReference.parse("service:maintenance.cleanup-service.cleanup-expired");
// → ServiceTarget("maintenance.cleanup-service.cleanup-expired")
CronTargetReference direct = CronTargetReference.parse("eventbus:integrations/legacy/reconcile");
// → EventBusTarget("integrations/legacy/reconcile")

For annotated @CronJob methods on service implementations, the target is derived automatically from the service contract metadata. Config-only jobs require the target field.

Manages timer registrations and job dispatches. Each registered job gets its own self-rescheduling Vert.x one-shot timer: after each fire, the next fire time is computed and the timer is re-registered immediately.

Concurrency controls:

  • Per-job overlap — OverlapPolicy per job definition. SKIP drops fires when the previous execution is in progress. QUEUE_ONE stores the most-recent skipped time and dispatches it after the current execution completes.
  • Global concurrency — maxConcurrentJobs (default: 10, CronScheduler.DEFAULT_MAX_CONCURRENT_JOBS) limits total concurrent jobs across all definitions. Excess dispatches are queued in a ConcurrentLinkedQueue and run as slots open.

SINGLE_INSTANCE leader election: All nodes race to insert a JobExecution row for the same (jobId, scheduledAt) via JobRepository.tryInsert(). The idx_job_executions_cron_dedup unique index ensures only one node wins; losers receive Optional.empty() and skip dispatch.

Per-execution features:

  • Consumer timeout: When executionTimeoutMs > 0, a local Vert.x timer marks the execution ABANDONED and releases the concurrency slot if no reply arrives in time. Set to 0 to disable.
  • Cooperative cancellation: Each dispatched execution registers a consumer on job.cancel.<executionId>. When a cancel message arrives, DefaultJobContext.setCancelled(true) is called. Handlers should poll ctx.isCancelled() and exit gracefully.
  • Progress flush: When progressFlushIntervalMs > 0 and a repository is available, a periodic timer writes changed ProgressSnapshot values to the repository.
  • Job log flush: A per-execution JobLogFlusher (dev.vertique:vertique-job-core) drains the execution’s buffered JobLogger entries to job_logs. It flushes on the same periodic tick as progress — unconditionally, since log entries change independently of the progress snapshot — and on every path that ends the execution: completion, timeout, and stop(). The periodic tick calls flush(); every ending site calls drain(), which awaits a write still outstanding from the tick and re-flushes while entries remain, bounded at 4 rounds. That bound matters because the ending site has just cancelled the tick that would otherwise have retried: entries still unpersisted when the rounds are spent are lost, and reported at WARN. job.coordinator.progressFlushIntervalMs (default 10000) governs only the periodic flush; 0 disables the tick, but the ending-site drains still run. An untracked fire (tracked=false, or no repository bound) is given a flusher built with a null execution id and never flushes — it has no job_executions row, and job_logs.execution_id is a foreign key onto that table. The final stop() drain is a bounded cutoff snapshot, not a guaranteed final flush: an in-flight execution’s handler is not interrupted, so entries it logs after the snapshot are lost.
  • Deferred-execution provenance: CronJobDispatcher binds a DeferredExecutionOrigin (kind = "cron", reference = the job id) into the dispatch context, proving the dispatch is deferred execution for the opt-in identity-snapshot reconstruction initializer.

Target resolution: The event bus address a fire dispatches to is resolved once per fire, immediately after the job is admitted past the overlap guard and before any execution record is written. A ServiceTarget is resolved through ServiceTargetResolver; an EventBusTarget uses its stored address. Both are then checked for a non-blank result, so a cron execution never records a blank handler.

A service: target that cannot be resolved — the resolver rejects the stable target id, or it resolves to a blank address — skips that one fire: no execution record is written, no message is sent, and the job is retried on its next scheduled tick. The job is not stalled, unregistered, or left holding a concurrency slot.

Whether it ever recovers depends on the resolver, and with the built-in one it does not. ServiceTargetResolver’s default implementation snapshots the service registry when it is constructed, so an operation that was absent at startup stays absent for the process lifetime — an unresolvable target id (a typo, or an operation that lives in a different deployment unit) means the job retries and fails forever until the application is restarted with the target resolvable. Only a custom resolver that reloads could make it recover in place.

Because that failure is effectively permanent while the retry is per-tick, the report is throttled: the first failure per job logs at ERROR with the cause and the sanitized job id and target; subsequent failures for that same job log at DEBUG until it resolves again (raise dev.vertique.job.cron to DEBUG to see them). Without the throttle, one mistyped target on a one-second cron would write an ERROR and a stack trace every second, indefinitely, on every node. Treat that single ERROR as a configuration alarm, not a transient blip.

Overlap handling is decided before resolution, so a QUEUE_ONE tick that arrives while an execution is running is still queued even if resolution would have failed. One caveat: if resolution fails at the moment the queued fire is taken, that queued tick is discarded rather than re-queued.

Synchronous failures cannot strand a job. A fire holds two guards: a per-job in-flight entry and one of maxConcurrentJobs global concurrency slots. Both are released by the execution’s completion callback, so anything that threw synchronously before that callback could run used to hold them for the process lifetime — and because a job’s own in-flight entry is what blocks its next tick, the symptom was a job that simply stopped, silently, after one error. A burned slot also permanently reduced how many jobs could run at once.

Every synchronous throw is now contained and both guards are released, so the fire is skipped and the job is retried on its next tick. This covers a custom JobRepository that throws instead of returning a failed future, or returns null (it is an application-implemented SPI, and the framework’s onFailure handling only catches a failed future); a JobInterceptor that throws from onDispatch; a failure constructing the dispatch envelope, including in an application-registered context encoder; and a failure on the event-bus send. It also covers a throw while persisting the terminal state on the completion and timeout paths, which previously ended the execution with the guards still held and nothing left able to release them. A tracked execution that failed this way is marked FAILED on a best-effort basis, and its buffered log entries are still drained.

One behavior worth knowing: a job whose handler or repository fails on every fire now logs once per fire rather than falling permanently silent after the first. That is louder, and truthful — the previous silence was the bug.

Interceptor asymmetry. On a synchronous dispatch failure JobInterceptor.onDispatch may already have run while onComplete never does, because there is no reply to complete. An interceptor that pairs the two — counting in-flight work, for instance — should treat a missing onComplete as possible rather than assume symmetry.

Tracked executions: When tracked=true and a repository is available, a JobExecution is persisted before dispatch and updated on completion. SINGLE_INSTANCE jobs are always tracked. The execution’s handler column records the address resolved for that fire, so job_executions.handler always equals the address the job was actually dispatched to.

Method Description
register(CronJobDefinition) Registers a job definition
start() Schedules first timers for all registered jobs; runs misfire recovery
stop() Cancels all timers, unregisters reply consumers, and takes a bounded cutoff drain of every in-flight execution’s buffered job logs

Scans all entries in ServiceContractRegistry for implementation methods annotated with @CronJob. Also registers config-only jobs (jobs with a target field in cron.jobs.* but no matching annotation). Called once at startup via scan(). All violations are collected before throwing CronRegistrationException (extends CronConfigurationException → core ConfigurationException) so the application fails fast with a complete error list. service: targets are stored as a stable CronTargetReference.ServiceTarget with a null handlerAddress and resolved to an event bus address once per fire via ServiceTargetResolver (not at registration) so the durable reference never pins a transport address. Note the built-in resolver snapshots the registry at construction, so “once per fire” bounds when the address is read, not how fresh it is.

Validation checks:

  • @CronJob is not placed on the contract interface method
  • Job ID is not blank and not duplicated
  • Cron expression is valid and parseable
  • Timezone ID is valid (ZoneId.of() succeeds)
  • Execution mode is recognized
  • Overlap policy is recognized
  • SINGLE_INSTANCE is only used when a JobRepository is available and overlap policy is SKIP
  • Implementation method corresponds to a registered operation in the service registry

Config resolution precedence (highest to lowest):

  1. Per-job config (cron.jobs.<id>.*)
  2. Annotation value
  3. Mode-based default (SINGLE_INSTANCE → FIRE_NOW; EVERY_INSTANCE → SKIP)

After registering all jobs, scan() persists schedule definitions to job_schedules via JobRepository.saveSchedule() (best-effort, fire-and-forget).

Thrown by CronJobRegistrar.scan() when validation collects one or more registration violations; the scheduler is left untouched. Extends CronConfigurationException, the module’s configuration-failure root, which in turn extends the core ConfigurationException.

Method Description
violations() Returns the unmodifiable list of individual violation messages

Immutable record describing a registered job:

Field Type Description
id String Unique job identifier
cronExpression CronExpression Parsed expression
target CronTargetReference Target reference (ServiceTarget or EventBusTarget); persisted in job_schedules.target
handlerAddress String Event bus address for an EventBusTarget job. Always null for a ServiceTarget job — that address is resolved per fire from target, never at startup
mode ExecutionMode Execution mode
timezone ZoneId Timezone for cron evaluation
maxAttempts int Max retry attempts
payload Object Static payload included in dispatch (may be null)
overlapPolicy OverlapPolicy Overlap handling policy
tracked boolean Whether to persist execution records
parameters Map<String,Object> Static key-value metadata for the handler (immutable)
misfirePolicy MisfirePolicy Misfire handling policy

{
"cron": {
"tracked": true,
"jobs": { ... }
}
}
Key Type Default Description
cron.tracked boolean true Global default for whether executions are persisted

Override or disable any annotation-defined job without code changes:

{
"cron": {
"jobs": {
"cleanup-expired": {
"enabled": true,
"cron": "0 0 3 * * *",
"timezone": "Europe/Helsinki",
"mode": "EVERY_INSTANCE",
"overlapPolicy": "QUEUE_ONE",
"tracked": false,
"misfirePolicy": "SKIP"
},
"daily-report": {
"enabled": false
}
}
}
}
Key Type Description
enabled boolean false to skip the job entirely (default: true)
cron string Override the cron expression
timezone string Override the timezone (IANA zone ID)
mode string Override execution mode (EVERY_INSTANCE or SINGLE_INSTANCE)
overlapPolicy string Override overlap policy (SKIP or QUEUE_ONE)
tracked boolean Override whether executions are persisted
misfirePolicy string Override misfire policy (FIRE_NOW, SKIP, FIRE_ALL)

Register jobs without an annotation by specifying a target field:

{
"cron": {
"jobs": {
"weekly-report": {
"target": "service:reporting.reports.generate-weekly-report",
"cron": "0 0 9 * * 1",
"timezone": "Europe/Helsinki",
"mode": "SINGLE_INSTANCE",
"parameters": {
"days": 30,
"format": "pdf"
}
},
"legacy-bridge": {
"target": "eventbus:integrations/legacy/reconcile",
"cron": "0 15 * * * *"
}
}
}
}

Config-only jobs support all the same per-job keys as annotation overrides, plus target and parameters. Use target: "eventbus:{address}" for direct event bus dispatch or target: "service:{stableTargetId}" for service contract operations.


Choose exactly one cron module:

No JobRepository required. SINGLE_INSTANCE mode is not available. Execution records are never persisted. ServiceTargetResolver must be provided (bound from the services module) for service: target resolution. This flavor omits JobCoordinatorModule, so there is no execution timeout and no progress flush — both intervals are effectively 0.

@Component(modules = {VertxModule.class, DispatchModule.class, CronModule.class})
public interface AppComponent {
VerticleDeploymentManager verticleDeploymentManager();
}

Requires a JobRepository binding (include JobPostgresqlModule and DbPostgresqlModule). Enables SINGLE_INSTANCE mode, execution tracking, and misfire recovery. Also includes JobCoordinatorModule.

@Component(modules = {
VertxModule.class,
DispatchModule.class,
DbPostgresqlModule.class,
DbFlywayModule.class,
JobPostgresqlModule.class,
CronPersistenceModule.class,
AppModule.class
})
public interface AppComponent {
VerticleDeploymentManager verticleDeploymentManager();
}

Both CronModule and CronPersistenceModule transitively include CronBaseModule, which contributes a VerticleDeployment named cron-scheduler at LifecyclePhase.SERVICES, priority 100. The deployment is handled by a package-private CronLifecycleVerticle that runs CronJobRegistrar.scan() then CronScheduler.start() on verticle start, and CronScheduler.stop() on stop.

No manual scan() / start() calls are needed. Including CronModule (or CronPersistenceModule) is sufficient: CronBaseModule contributes the cron-scheduler VerticleDeployment at LifecyclePhase.SERVICES, and the lifecycle runner deploys every verticle phase, so the scheduler starts automatically — there is no separate cron ApplicationStartupStep.

Because the scheduler is contributed at SERVICES, it starts only if that phase is deployed. The lifecycle runner (VertiqueApplicationBootstrap) always deploys all four verticle phases, so an application that delegates startup to it cannot miss the scheduler. A host that instead sequences phases by hand deploys only the phases it names, and cron never runs — with no error and no log. Delegate startup to the runner; see dev.vertique:vertique-deploy for why deployPhase is a framework primitive rather than an application entry point.

CronJobRegistrar and CronScheduler remain accessible on AppComponent for advanced use cases (custom integrations, tests) but apps no longer need to call them directly during startup.

scan() is transactional: validation collects all violations before throwing CronRegistrationException (extends CronConfigurationException → core ConfigurationException). If any violations exist, the scheduler is left untouched — no partial registrations. persistSchedule() is wrapped in a top-level try/catch (Throwable) so a synchronous throw from a custom JobRepository cannot escape scan().

Rollback on start failure: if scheduler.start() returns a failed Future after scan() succeeded, CronLifecycleVerticle invokes scheduler.stop() best-effort before propagating the original cause (Vert.x does not call stop() on a failed start()).

Contributing JobInterceptors:

@Provides @IntoSet
static JobInterceptor metricsInterceptor(MetricsService metrics) {
return new MetricsJobInterceptor(metrics);
}

CronConcurrencyManager, CronJobDispatcher, CronMisfireRecovery, CronLifecycleVerticle, CronPersistenceMarker, and CronBaseModule are framework seams. Their Javadoc marks them INTERNAL. Applications include CronModule or CronPersistenceModule and use the declaration and configuration surfaces above; they never construct these collaborators.

  • job-core — JobContext, JobDispatchContext, JobInterceptor, JobRepository, JobCoordinatorConfig, CronJobSchedule, JobModule, JobCoordinatorModule
  • core — EventBusClient, ContextHolder, DeferredExecutionOrigin, LifecyclePhase, ConfigParser, ConfigurationException, KeyedBy
  • context — DispatchEnvelopeBuilder, ContextRuntimeModule (wired via CronModule/CronPersistenceModule)
  • services — ServiceContractRegistry (scanned for @CronJob annotations), ServiceMethodInvoker (dispatches events and injects JobContext/JobDispatchContext), ServiceTargetResolver (mandatory; resolves service: target references once per fire)
  • deploy — VerticleDeployment, LifecyclePhase, DeployerModule (wired via CronBaseModule to auto-deploy CronLifecycleVerticle)
  • logging — MDC utilities

MDC note. CronJobDispatcher enriches MDC before dispatching each job execution. Enrichment that runs on the scheduler verticle thread (non-duplicated context) uses org.slf4j.MDC directly because the framework facade (dev.vertique.logging.MDC) requires a duplicated Vert.x context and would throw otherwise. Enrichment inside event-bus consumer handlers (which run on duplicated contexts) uses the framework facade normally.