Scheduling
Ratchet supports three scheduling modes: immediate execution, delayed execution, and cron-based recurring schedules. All scheduling operations are transactional -- jobs are persisted atomically with your business data.
Scheduling Modes
Immediate Execution
The simplest mode. The job is persisted with scheduled_time = now and becomes eligible for the next poll cycle.
// Fire-and-forget (no configuration needed)
scheduler.enqueueNow(() -> emailService.sendWelcome(userId));
// With configuration
scheduler.enqueue(() -> orderService.processOrder(orderId))
.withPriority(JobPriority.HIGH)
.withMaxRetries(3)
.submit();For truly urgent work, mark the job as immediate() or use CRITICAL priority. This triggers a wakeup notification via the ClusterCoordinator SPI, causing all cluster nodes to poll immediately instead of waiting for the next adaptive polling cycle:
scheduler.enqueue(() -> alertService.sendCriticalAlert(incident))
.withPriority(JobPriority.CRITICAL) // auto-triggers wakeup
.submit();
// Or explicitly:
scheduler.enqueue(() -> paymentService.capture(paymentId))
.immediate() // triggers cluster wakeup
.submit();Delayed Execution
Schedule a job to run after a specified delay:
// Send a reminder in 30 minutes
scheduler.schedule(Duration.ofMinutes(30), () -> reminderService.send(userId))
.submit();
// Retry with custom delay
scheduler.schedule(Duration.ofHours(1), () -> syncService.retryFailed(batchId))
.withMaxRetries(5)
.submit();The delay is computed at submission time: scheduled_time = now + delay. The job stays invisible to the Poller until that time passes.
Cron-Based Recurring
Recurring jobs use Quartz cron expressions (6-7 fields):
second minute hour day-of-month month day-of-week [year]Annotation-Based (Declarative)
The most common approach for recurring jobs:
@ApplicationScoped
public class MaintenanceService {
@Recurring(cron = "0 0 2 * * ?", name = "Nightly Cleanup")
public void performCleanup() {
// Runs at 2 AM daily (UTC)
}
@Recurring(
cron = "0 */15 * * * ?",
zone = "America/New_York",
name = "Health Check",
priority = 8,
maxRetries = 5,
backoffPolicy = BackoffPolicy.EXPONENTIAL,
tags = {"health", "monitoring"}
)
public void healthCheck(JobContext context) {
context.logger().info("Health check running");
}
}Method requirements:
- Must be
public - Must be on a CDI-managed bean (
@ApplicationScoped,@Stateless, etc.) - Must have either no parameters or a single
JobContextparameter - Return type can be anything (return values are ignored)
At startup, RecurringJobProcessor scans all CDI beans for @Recurring methods, validates them via RecurringMethodValidator, and registers them with the scheduler. The annotation's id property (defaulting to the fully qualified class + method name) is used as the business key to ensure exactly one active recurring master exists per annotation.
Programmatic API
For runtime-defined schedules:
scheduler.scheduleRecurring(
"0 0 * * * ?",
ZoneId.of("UTC"),
() -> reportService.generateHourly())
.withOptions(JobOptions.defaults()
.withMaxRetries(3)
.withTimeout(Duration.ofMinutes(10)))
.withTags(List.of("reports"))
.withBusinessKey("hourly-report")
.withMisfirePolicy(RecurringMisfirePolicy.fireOnce())
.submit();Downtime and misfires
When at least two cron occurrences are overdue, the recurring master's persisted misfire policy decides what Ratchet creates:
| Policy | Backlog behavior |
|---|---|
RecurringMisfirePolicy.skip() | Discard every overdue occurrence and resume in the future |
RecurringMisfirePolicy.fireOnce() | Create only the oldest overdue occurrence, then resume in the future |
RecurringMisfirePolicy.catchUp(n) | Create at most n overdue occurrences in scheduled order |
The default is catchUp(11), matching Ratchet's earlier bounded catch-up behavior. A single overdue occurrence is not a backlog and runs normally under all three policies. Annotation-based schedules use misfirePolicy plus maxCatchUpExecutions; the limit is ignored for SKIP and FIRE_ONCE.
Cron Expression Examples
| Expression | Schedule |
|---|---|
0 0 2 * * ? | Every day at 2:00 AM |
0 */15 * * * ? | Every 15 minutes |
0 0 9 ? * MON | Every Monday at 9:00 AM |
0 0 0 1 * ? | First day of every month at midnight |
0 30 8 ? * MON-FRI | Weekdays at 8:30 AM |
0 0 */4 * * ? | Every 4 hours |
Timezone Handling
Cron expressions are evaluated in the specified timezone. This matters for schedules that cross DST boundaries:
@Recurring(
cron = "0 0 2 * * ?",
zone = "America/New_York" // respects EST/EDT transitions
)
public void nightlyJob() { ... }If no timezone is specified, UTC is used. When the clock jumps forward, a local time that does not exist is skipped. When the clock falls back and repeats a local time, Ratchet fires the first occurrence only. Use UTC when every nominal occurrence matters more than local wall-clock behavior.
The enqueue vs schedule vs recurring API
| Method | Delay | Recurring | Returns |
|---|---|---|---|
enqueue(task) | None (immediate) | No | JobBuilder |
enqueueNow(task) | None (immediate) | No | JobHandle (no further config) |
schedule(delay, task) | Specified duration | No | JobBuilder |
scheduleRecurring(cron, zone, task) | Cron-based | Yes | RecurringJobBuilder |
enqueueBatch(name) | None | No | BatchBuilder |
streamingBatch(name) | None | No | StreamingBatchBuilder |
Idempotency and Deduplication
Every job gets an idempotency key -- a globally unique identifier that prevents duplicate creation. By default, a UUID is auto-generated at builder creation time. You can provide a custom key for application-level deduplication:
// Same webhook delivery ID = same job, forever
scheduler.enqueue(() -> processWebhook(payload))
.withIdempotencyKey(webhookDeliveryId)
.submit();The idempotency key is enforced by a UNIQUE constraint in the database. Submitting a duplicate key returns the original job's handle. If a racing insert throws DuplicateIdempotencyKeyException, retry in a fresh transaction to get that handle.
Business keys serve a different purpose -- they prevent concurrent execution of the same logical operation, but allow re-runs after completion:
// Only one sync per user at a time, but re-runs allowed
scheduler.enqueue(() -> syncUser(userId))
.withBusinessKey("sync-user-" + userId)
.submit();Business keys are enforced as unique only among active jobs (PENDING, RUNNING, PAUSED, WAITING). Submitting another job with the same business key throws DuplicateBusinessKeyException. Retrying does not help until the active job reaches a terminal state and frees the key for reuse.
For consistent behavior across stores, business keys are limited to 255 printable ASCII characters after trimming. Ratchet rejects longer or non-ASCII keys before persistence.
| Mechanism | Scope | Lifetime | Use Case |
|---|---|---|---|
| Idempotency Key | Globally unique | Forever | Webhook IDs, payment request IDs |
| Business Key | Active-unique | Until terminal state | "One sync per user at a time" |
Job Priority
Priority determines which jobs are claimed first when multiple are eligible. The JobPriority enum defines five levels:
| Priority | Ordinal | Behavior |
|---|---|---|
LOWEST | 0 | Last to be picked up |
LOW | 1 | Below normal |
NORMAL | 2 | Default for all jobs |
HIGH | 3 | Preferred over normal |
CRITICAL | 4 | Picked first; triggers immediate wakeup |
The Poller's claim query orders by effective priority, then due time. Effective priority is the persisted priority plus an age-based boost:
effective_priority = priority + floor(wait_minutes / priorityBoostIntervalMinutes)With the default 15-minute boost interval, long-waiting low-priority jobs can outrank newer high-priority jobs. This is intentional starvation prevention. Set RatchetOptions.builder().store(s -> s.priorityBoostIntervalMinutes(0)) to disable boosting and use raw priority ordering.
scheduler.enqueue(() -> billingService.process(invoice))
.withPriority(JobPriority.HIGH)
.submit();:::caution Priority ordinal values are persisted in the database. Do not reorder or insert between existing enum values -- this would silently corrupt the priority of existing jobs. :::
Job Replacement
To replace an existing job with a new definition:
JobHandle newHandle = scheduler.replace(
existingJobId,
Duration.ofMinutes(5),
() -> updatedService.process(newData),
JobOptions.defaults().withMaxRetries(2)
);The old job is marked as superseded (superseded_by column points to the new job), and the new job takes its place. This is useful for debouncing -- replacing a pending job with an updated version instead of creating duplicates.
Adaptive Polling
The engine doesn't poll at a fixed interval. The PollingStrategy dynamically adjusts the polling delay based on job availability patterns:
The strategy considers multiple factors:
- Rolling window: Tracks job counts over the last 10 polls to identify trends
- Full batch detection: When consecutive polls return full batches, polling becomes more aggressive
- Load factor: Adjusts based on thread pool utilization
- Idle detection: Progressive backoff when no jobs are found
- Deep idle: Extended delay after 5 minutes of inactivity
- Burst mode: Aggressive polling after a wakeup notification
This means Ratchet is responsive to new work (sub-second latency with wakeup) while consuming minimal resources during quiet periods.
Resource Limiting
Jobs can declare a shared resource they require, enabling cross-job-type concurrency control:
// Limit concurrent payment API calls to the configured pool size
scheduler.enqueue(() -> paymentService.charge(paymentId))
.withResource("payment-api")
.submit();When a job with a resource runs, it attempts to acquire a permit from the ResourcePermitService. If no permits are available (resource at capacity), the job is rescheduled with a delay -- without counting as a retry attempt. This is distinct from retry logic: the job isn't failing, it's waiting for capacity.
Batch builders can apply a resource to every child with withResource. The resource permit count caps how many children run at once without lowering pool-wide concurrency. All jobs using that resource share the same permits. The parent, progress hooks, and workflow branches do not acquire them.
scheduler.enqueueBatch("Payments")
.forEach(paymentIds, id -> paymentService.charge(id))
.withResource("payment-api")
.submit();Related
- Execution Model -- How polling and execution work together
- Job Lifecycle -- State transitions triggered by scheduling
- Retry Strategies -- Backoff between retry attempts
- Clustering -- Wakeup notifications across nodes