Skip to content

Persistence

Ratchet persists all job state in the selected store backend. SQL stores use JPA entities and DDL-backed tables; the MongoDB store maps the same model to documents and collections. The shared persistence layer is built around a composable SPI interface, UUIDv7 identifiers, and dialect-specific constraint detection where the backend needs it.

Entity Model

The core logical model is JobEntity. In SQL stores it is split across a cold metadata table (scheduler_job) and a hot executable queue table (scheduler_job_queue). The cold table owns immutable job shape and terminal history; the hot table exists only while a job is live and owns claim/poll state. MongoDB maps the same logical model to collections. Supporting entities handle batches, executions, workflow conditions, locks, nodes, structured logs, resource limits, and archived jobs.

SQL Job Tables

The SQL stores denormalize a few immutable fields into scheduler_job_queue (job_type, priority, business_key, timeout_sec, max_retries) so the claim path can populate lightweight claim DTOs from one hot table.

ColumnTypePurpose
job_idBINARY(16)/uuid (UUIDv7)Primary key, time-ordered
scheduler_job.job_typeENUM/TEXTInternal execution type (SINGLE, BATCH_CHILD, etc.)
scheduler_job.priorityINTPriority ordinal (0=LOWEST to 4=CRITICAL)
scheduler_job.payloadJSONSerialized job definition (target, method, args)
scheduler_job.paramsJSONKey-value parameters accessible via JobContext
idempotency_keyVARCHAR(36) UNIQUEGlobally unique deduplication key
business_keyVARCHARActive-unique key for concurrent execution prevention
depends_onBINARY(16)/uuidFK to parent job for chains
superseded_byBINARY(16)/uuidFK to replacement job
caller_principalVARCHAR(255)Captured Jakarta Security caller principal, if available
resource_nameVARCHAR(100)Resource pool for permit acquisition
terminal_status / terminal_errorENUM/TEXT / TEXTCold survivor fields set at terminal transition
scheduler_job_queue.statusENUM/TEXTLive lifecycle state (PENDING, RUNNING, PAUSED, WAITING)
scheduler_job_queue.scheduled_timeTIMESTAMPWhen the job becomes eligible for polling
scheduler_job_queue.attemptsINTCurrent attempt count while live
scheduler_job_queue.picked_by / picked_atVARCHAR(64) / TIMESTAMPNode claim ownership and claim time
scheduler_job_queue.versionINTOptimistic locking version for live queue mutations
scheduler_job_queue.signal_key / signal_timeoutVARCHAR / TIMESTAMPSignal-wait key and timeout for WAITING jobs
scheduler_job_queue.signal_payload / metadataTEXT / VARCHARDelivered signal payload, decision metadata, and delivery id

The scheduler_business_key_reservation table (or MongoDB collection) owns active business-key uniqueness across queue jobs and recurring masters. Terminal rows keep their business_key for audit/search, but they do not block a future active job from using the same key.

The physical column is wider in some stores, but the API contract is the portable minimum: after trimming, a business key may contain up to 255 printable ASCII characters. Oracle can count its VARCHAR2(255) limit in bytes, and SQL Server's indexed VARCHAR columns use the database collation's code page. Printable ASCII is one byte and round-trips unchanged in both, so Ratchet rejects other values before persistence rather than allowing store-specific conversion or failure.

Indexes

SQL stores define hot queue indexes for the Poller and supporting cold-table indexes for traversal/search. MongoDB defines analogous collection indexes in ratchet-store-mongodb:

IndexColumnsPurpose
idx_claim_executablescheduler_job_queue(job_type, scheduled_time, priority, job_id) for pending rowsExecutable claim filter
idx_queue_orphanscheduler_job_queue(status, picked_at, picked_by)Orphan recovery by node
pk_scheduler_business_key_reservationscheduler_business_key_reservation(business_key)Active business-key uniqueness and lookup
idx_job_depends_onscheduler_job(depends_on)Chain/workflow traversal
idx_job_superseded_byscheduler_job(superseded_by)Replacement lookup
idx_job_created_atscheduler_job(created_at)Operational search and retention
idx_rec_claimscheduler_recurring_job(next_fire) WHERE is_paused = FALSE (PostgreSQL) / scheduler_recurring_job(is_paused, next_fire) (MySQL)Recurring-master claim scheduling
idx_signal_key_statusscheduler_job_queue(signal_key, status)Atomic signal delivery by key
idx_signal_timeout_statusscheduler_job_queue(status, signal_timeout)Signal timeout scans
idx_signal_delivery_idscheduler_job_queue(signal_delivery_id)Signal delivery event lookup

UUIDv7 Identifiers

Ratchet uses RFC 9562 §5.7 UUIDv7 for primary keys. UUIDs are 128-bit values that are time-ordered, coordination-free, and globally unique.

Layout

FieldBitsPurpose
unix_ts_ms48Wall-clock millisecond timestamp
ver4Version constant 7
rand_a12Per-millisecond monotonic counter
var2RFC 9562 variant constant 10
rand_b62Cryptographic random (SecureRandom)

Properties

  • Time-ordered: The 48-bit timestamp prefix preserves B-tree locality: inserts cluster at the right edge, and range scans by time work directly.
  • Monotonic within a millisecond: rand_a is used as a per-ms counter; on overflow inside a single ms, generation busy-spins via Thread.onSpinWait until the wall clock advances (RFC 9562 §6.2 wait-for-tick). The timestamp is never advanced past wall-clock time.
  • Coordination-free: 62 bits of randomness in rand_b make collisions vanishingly unlikely without inter-node coordination.
  • 128-bit java.util.UUID: Standard Java type, no special storage adapter on PostgreSQL (native uuid). MySQL stores as BINARY(16) and uses the MySQL store's META-INF/orm-mysql.xml mapping plus UuidByteArrayConverter so non-Hibernate JPA providers bind UUID fields as 16 bytes. Oracle stores as RAW(16) and uses the Oracle store's META-INF/orm-oracle.xml mapping plus UuidRawConverter the same way. SQL Server stores as BINARY(16) and uses the SQL Server store's META-INF/orm-sqlserver.xml mapping plus UuidByteArrayConverter the same way. MongoDB stores BSON UUID subtype 4 (UuidRepresentation.STANDARD).

Utility Methods

java
// Generate a new UUIDv7
UUID id = UuidV7Factory.create();

Why UUIDv7 Instead of TSID or Auto-Increment

ConcernAuto-IncrementTSIDUUIDv7
Multi-node generationRequires coordinationManual node-id slot (10 bits = 1024 nodes)Coordination-free
Concurrent generators before collisionsn/a~38 (birthday paradox on 10-bit node + 12-bit seq)Effectively unbounded (62 random bits)
Insert contentionB-tree hotspotDistributedDistributed (timestamp prefix only)
Temporal orderingNeeds created_atEmbeddedEmbedded
Range scan by timeNeeds indexUse IDUse ID
Migration / mergeConflictsRisk if node ids reusedGlobally unique

JobStore SPI

The mandatory JobStore interface composes the persistence concerns every store must provide. Optional capabilities are not part of it: a store advertises one by also implementing its interface, and callers probe for it with capability() rather than assuming it is present. A minimal backend implements only the core through one CDI bean; the shipped MySQL, PostgreSQL, Oracle, SQL Server, and MongoDB stores advertise every capability.

java
public interface JobStore
    extends JobCrudStore,
            JobClaimStore,
            JobTerminalStore,
            JobRetryStore,
            JobPauseStore,
            JobBatchStatusStore,
            JobBulkStore,
            NodeStore,
            TagStore {

    // Probe for an optional capability this store may also implement.
    default <T> Optional<T> capability(Class<T> type) {
        return type.isInstance(this) ? Optional.of(type.cast(this)) : Optional.empty();
    }
}

Core Interface Responsibilities (mandatory)

InterfaceResponsibility
JobCrudStoreCreate, read, update, delete individual jobs
JobClaimStoreAtomic batch claiming (SKIP LOCKED for SQL stores, atomic updates for MongoDB)
JobTerminalStoreTerminal success, failure, and cancellation transitions
JobRetryStoreRetry scheduling and attempt-state updates
JobPauseStorePause and resume transitions
JobBatchStatusStoreNon-terminal status, pickup, and orphan operations
JobBulkStoreBulk operations (DLQ purge, batch insert)
NodeStoreNode registration, heartbeat, and crash recovery
TagStoreJob tag writes

Optional Capability Responsibilities

CapabilityResponsibility
RecurringJobStoreRecurring-master persistence (claim, advance, cancel/archive)
BatchStoreBatch parent/child management, progress tracking, and metrics
WorkflowConditionStoreWorkflow condition persistence and retrieval
SignalStoreAtomic signal delivery, signal-timeout scans, and signal-event lookup
ResourcePermitStoreResource permit acquisition and release
LockStoreDistributed lock acquisition and release
ArchiveStoreJob archival to the archive table/collection
JobQueryStoreRead-only list/detail/queue-health queries and tag lookups
JobAnalyticsStoreAggregate counts, rate statistics, and percentile metrics
JobAuditStoreExecution history recording and structured job log storage
JobExtensionStoreIndexed job properties (scheduler_job_properties) and mutable per-namespace extension state with optimistic CAS (scheduler_job_extension_state), used by framework extensions; archiving copies both onto the archive row as denormalized JSON

Why a Core-Plus-Capabilities Split?

The decomposition serves multiple purposes:

  1. No forced surface: A store implements only what its backend can support. An in-memory test double or a Redis backend can ship the core and skip archiving, analytics, or distributed locks.
  2. Conditional conformance: The TCK reports a capability contract as N/A for a store that does not advertise it, so a core-only store stays conformant rather than failing for absent features.
  3. Cognitive load: Each interface has a focused, understandable contract.
  4. Dependency injection: RI services depend only on the interfaces they need (e.g. Poller depends on JobClaimStore, not the full JobStore), and resolve optional capabilities through a nullable Instance<T>.

Constraint Detection

Different databases report constraint violations differently. The ConstraintDetector interface abstracts this:

java
public interface ConstraintDetector {
    @Nullable String constraintName(Exception e);
    boolean isDuplicateKey(Exception e);
    default boolean isDuplicateBusinessKey(Exception e);
    boolean isDeadlock(Exception e);
    boolean isTransientConnectionFailure(Exception e);
}

Each SQL store module provides a dialect-specific implementation:

  • MySQL: Parses for "Duplicate entry" in the error message
  • PostgreSQL: Checks SQL state codes (23505 for unique violation, plus deadlock/serialization and connection-failure states)

This is used primarily for idempotency key enforcement: when a duplicate key is detected, the submission is silently rejected rather than throwing an error to the caller.

DDL Schema

SQL store modules ship DDL as plain SQL files in src/main/resources/ddl/. The *-schema.sql file is the authoritative clean-install schema for that dialect, and it reserves a ratchet_schema_version table for ordered upgrades.

Ratchet still does not run migrations automatically by default. Your application remains responsible for applying schema changes, whether through Flyway, Liquibase, or another deployment-time mechanism. When incremental Ratchet migration scripts are added, they live under ddl/migrations/ and follow the V###__description.sql convention. Those ordered V* files must compose to the same schema shipped in the clean-install DDL.

For SQL stores, if you do not already use a migration framework, ratchet-store-core also exposes SchemaMigrator, a small optional utility that discovers ordered V* scripts, serializes startup with a database advisory lock, validates checksums in ratchet_schema_version, and applies only pending scripts. Call it from a SchedulerLifecycleHook.beforeStart hook so migrations finish before the poller starts claiming jobs.

stores/ratchet-store-mysql/src/main/resources/ddl/mysql-schema.sql
stores/ratchet-store-postgresql/src/main/resources/ddl/postgresql-schema.sql

MongoDB does not ship SQL DDL. The ratchet-store-mongodb module creates the required collections and indexes at startup.

UUID Inspection by Store

  • PostgreSQL: query UUID columns directly; psql renders native uuid values as hyphenated strings.
  • MySQL: raw BINARY(16) values are not readable in CLI output. Apply the optional ddl/views/vw_jobs.sql operator views and query those views for hyphenated UUID strings. The views use BIN_TO_UUID(col) with no swap flag; BIN_TO_UUID(col, 1) is for MySQL's UUIDv1 time-reorder format and does not match Ratchet's Java-standard byte order.
  • MongoDB: use a MongoClient configured with UuidRepresentation.STANDARD so BSON subtype 4 UUID values round-trip correctly; mongosh renders them as UUID("...").

Optimistic Locking

SQL stores use JPA @Version on the version column, while MongoDB uses atomic filter-and-update operations. Both paths prevent lost updates when two nodes attempt to modify the same job concurrently. The engine uses compare-and-swap patterns for critical transitions:

java
// Atomic status transition — fails if another thread changed the status
boolean success = jobStore.compareAndSwapStatus(
    jobId, JobStatus.RUNNING, JobStatus.SUCCEEDED, null);

Combined with FOR UPDATE SKIP LOCKED in SQL stores or atomic document claiming in MongoDB, this ensures a ready job is claimed by only one node at a time.