Job cron
View MarkdownStatus: Stable Package:
dev.vertique.job.cronArtifact:vertique-job-cronDepends 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.
Key Classes
Section titled “Key Classes”@CronJob
Section titled “@CronJob”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 midnightSupported 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.
CronExpression
Section titled “CronExpression”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 |
ExecutionMode
Section titled “ExecutionMode”| 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 |
OverlapPolicy
Section titled “OverlapPolicy”| 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. |
MisfirePolicy
Section titled “MisfirePolicy”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.
CronTargetReference
Section titled “CronTargetReference”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.
CronScheduler
Section titled “CronScheduler”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 —
OverlapPolicyper job definition.SKIPdrops fires when the previous execution is in progress.QUEUE_ONEstores 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 aConcurrentLinkedQueueand 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 executionABANDONEDand releases the concurrency slot if no reply arrives in time. Set to0to 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 pollctx.isCancelled()and exit gracefully. - Progress flush: When
progressFlushIntervalMs > 0and a repository is available, a periodic timer writes changedProgressSnapshotvalues to the repository. - Job log flush: A per-execution
JobLogFlusher(dev.vertique:vertique-job-core) drains the execution’s bufferedJobLoggerentries tojob_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, andstop(). The periodic tick callsflush(); every ending site callsdrain(), 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(default10000) governs only the periodic flush;0disables the tick, but the ending-site drains still run. An untracked fire (tracked=false, or no repository bound) is given a flusher built with anullexecution id and never flushes — it has nojob_executionsrow, andjob_logs.execution_idis a foreign key onto that table. The finalstop()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:
CronJobDispatcherbinds aDeferredExecutionOrigin(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 |
CronJobRegistrar
Section titled “CronJobRegistrar”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:
@CronJobis 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_INSTANCEis only used when aJobRepositoryis available and overlap policy isSKIP- Implementation method corresponds to a registered operation in the service registry
Config resolution precedence (highest to lowest):
- Per-job config (
cron.jobs.<id>.*) - Annotation value
- 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).
CronRegistrationException
Section titled “CronRegistrationException”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 |
CronJobDefinition
Section titled “CronJobDefinition”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 |
Configuration
Section titled “Configuration”Global settings
Section titled “Global settings”{ "cron": { "tracked": true, "jobs": { ... } }}| Key | Type | Default | Description |
|---|---|---|---|
cron.tracked |
boolean | true |
Global default for whether executions are persisted |
Per-job overrides
Section titled “Per-job overrides”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) |
Config-only jobs
Section titled “Config-only jobs”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.
Dagger Wiring
Section titled “Dagger Wiring”Choose exactly one cron module:
CronModule (in-memory, no DB)
Section titled “CronModule (in-memory, no DB)”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();}CronPersistenceModule (DB-backed)
Section titled “CronPersistenceModule (DB-backed)”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();}Auto-deployment
Section titled “Auto-deployment”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 @IntoSetstatic JobInterceptor metricsInterceptor(MetricsService metrics) { return new MetricsJobInterceptor(metrics);}Framework seams
Section titled “Framework seams”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.
Dependencies
Section titled “Dependencies”- job-core —
JobContext,JobDispatchContext,JobInterceptor,JobRepository,JobCoordinatorConfig,CronJobSchedule,JobModule,JobCoordinatorModule - core —
EventBusClient,ContextHolder,DeferredExecutionOrigin,LifecyclePhase,ConfigParser,ConfigurationException,KeyedBy - context —
DispatchEnvelopeBuilder,ContextRuntimeModule(wired viaCronModule/CronPersistenceModule) - services —
ServiceContractRegistry(scanned for@CronJobannotations),ServiceMethodInvoker(dispatches events and injectsJobContext/JobDispatchContext),ServiceTargetResolver(mandatory; resolvesservice:target references once per fire) - deploy —
VerticleDeployment,LifecyclePhase,DeployerModule(wired viaCronBaseModuleto auto-deployCronLifecycleVerticle) - logging — MDC utilities
MDC note.
CronJobDispatcherenriches MDC before dispatching each job execution. Enrichment that runs on the scheduler verticle thread (non-duplicated context) usesorg.slf4j.MDCdirectly 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.