diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/jobs/JobHandler.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/jobs/JobHandler.kt new file mode 100644 index 0000000..9c05cad --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/jobs/JobHandler.kt @@ -0,0 +1,121 @@ +package org.vibeerp.api.v1.jobs + +import org.vibeerp.api.v1.security.Principal +import java.time.Instant +import java.util.Locale + +/** + * Implementation contract for a recurring or one-shot background job. + * + * A [JobHandler] is to the scheduler what a [org.vibeerp.api.v1.workflow.TaskHandler] + * is to the workflow engine: a plug-in (or core PBC) supplies a small + * piece of code that runs inside an engine-managed execution context. + * The engine owns the when (cron, one-shot, manual trigger), the + * transactional boundary, the principal context, and the structured + * logging tags; the handler owns the what. + * + * ``` + * @Component + * class PruneAuditLogJob : JobHandler { + * override fun key() = "core.audit.prune" + * override fun execute(context: JobContext) { + * // delete audit rows older than 90 days + * } + * } + * ``` + * + * **Why the handler is decoupled from Quartz** (same rationale as + * `TaskHandler` vs Flowable): plug-ins must not import the concrete + * scheduler type. The platform adapts Quartz to this api.v1 shape + * inside its `platform-jobs` host module. If vibe_erp ever swaps + * Quartz for a different scheduler, every handler keeps working + * without a single line change. + * + * **Registration:** + * - Core handlers are `@Component` Spring beans and get picked up by + * the framework's `JobHandlerRegistry` via constructor injection. + * - Plug-in handlers are registered from inside `Plugin.start(context)` + * via a future `context.jobs.register(handler)` hook (the seam is + * defined, the plug-in loader integration lands when a real + * plug-in needs it). + * + * Duplicate-key rejection at registration time, same discipline as + * [org.vibeerp.api.v1.workflow.TaskHandler]. + */ +public interface JobHandler { + + /** + * Stable, unique identifier for this handler. Convention: + * `..`. Used by the scheduler + * to route execution to the right handler. Two handlers in the + * same running deployment must not return the same key — the + * framework fails registration on conflict. + */ + public fun key(): String + + /** + * Execute the job. The context carries principal, correlation id, + * locale, and any job-data the scheduler/trigger passed in at + * schedule time. Throwing unwinds the surrounding Spring + * transaction (if any) and marks the Quartz trigger as MISFIRED + * so the operator sees it in the HTTP surface. + */ + public fun execute(context: JobContext) +} + +/** + * The runtime context for a single [JobHandler.execute] invocation. + * + * Analogous to `org.vibeerp.api.v1.workflow.TaskContext` but for the + * scheduler instead of BPMN. The scheduler host fills in every field + * before invoking the handler; the handler is read-only over this + * context. + * + * **Why no `set(name, value)` writeback** (unlike `TaskContext`): + * scheduled jobs don't produce continuation state for a downstream + * step the way a BPMN service task does. A job that needs to + * communicate with the rest of the system writes to its own domain + * table or publishes an event via the PBC's `eventBus`. Keeping + * [JobContext] read-only makes the handler body trivial to reason + * about. + */ +public interface JobContext { + + /** + * The principal the job runs as. For manually triggered jobs, + * this is the authenticated user who hit the "trigger" endpoint. + * For scheduled jobs, it's a `Principal.System` whose name is + * `jobs:` so audit columns get a structured, greppable + * value. + */ + public fun principal(): Principal + + /** + * The locale to use for any user-facing strings the job produces + * (notification titles, log-line i18n, etc.). For a scheduled + * job this is the framework default; for a manual trigger it + * propagates from the HTTP caller. + */ + public fun locale(): Locale + + /** + * Correlation id for this job execution. Appears in the + * structured logs and the audit log so an operator can trace + * a single job run end-to-end. + */ + public fun correlationId(): String + + /** + * Instant the job actually started running, from the scheduler's + * perspective. May differ from the firing time stored on the + * trigger when the job was queued behind other work. + */ + public fun startedAt(): Instant + + /** + * Read-only view of the string-keyed data the trigger passed to + * this execution. The scheduler preserves insertion order; the + * handler must not mutate the returned map. + */ + public fun data(): Map +} diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/jobs/JobScheduler.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/jobs/JobScheduler.kt new file mode 100644 index 0000000..2e34a13 --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/jobs/JobScheduler.kt @@ -0,0 +1,136 @@ +package org.vibeerp.api.v1.jobs + +import java.time.Instant + +/** + * Cross-PBC facade for the framework's background-job scheduler. + * + * **The second injectable platform service after `EventBus`.** Any + * PBC, platform module, or plug-in that needs to kick off work on a + * schedule, on a delay, or on demand injects [JobScheduler] (never a + * concrete Quartz `Scheduler`) and uses the methods below. The host + * module `platform-jobs` provides the only concrete implementation. + * + * **Scheduling shapes:** + * - [scheduleCron] — cron-driven recurring job (e.g. "every night at 02:00"). + * Quartz cron expressions (6 or 7 fields) are accepted as-is. + * - [scheduleOnce] — one-shot, fires once at a given instant. Useful + * for delayed follow-ups ("approve this request in 24h if no one + * has responded") without introducing a sleep-based anti-pattern. + * - [triggerNow] — fire the handler immediately, NOT through a + * persisted Quartz job. Used by the HTTP trigger endpoint and by + * tests. Runs synchronously on the caller's thread so a test + * can assert the effect inline. + * + * **Identifier semantics:** + * - The [handlerKey] identifies WHICH handler to run. It must match + * a registered [JobHandler.key]. + * - The [scheduleKey] identifies WHICH schedule this is. Two + * schedules for the same handler are legal — you can have "every + * night" AND "every Monday at noon" for the same prune-audit-log + * handler. Schedule keys must be unique across the running + * scheduler and are the handle used to [unschedule]. + * + * **Idempotency:** [scheduleCron] and [scheduleOnce] are both + * idempotent on [scheduleKey]. Calling with the same key twice + * replaces the existing schedule (cron expression, fire time, and + * data are all overwritten). Use [unschedule] to remove. + */ +public interface JobScheduler { + + /** + * Schedule [handlerKey] to run on a recurring [cronExpression]. + * If a schedule with [scheduleKey] already exists, it is + * replaced. + * + * @param scheduleKey the operator-facing handle for this schedule. + * Convention: `:` e.g. + * `core.audit.prune:nightly`. + * @param handlerKey a registered [JobHandler.key]. + * @param cronExpression a Quartz cron expression with 6 or 7 fields. + * The framework does not translate from Unix cron — Quartz's + * syntax is the contract. + * @param data free-form key/value pairs passed into [JobContext.data] + * when the handler runs. + * @throws IllegalArgumentException if [handlerKey] is not + * registered, the cron expression is invalid, or any of the + * required strings is blank. + */ + public fun scheduleCron( + scheduleKey: String, + handlerKey: String, + cronExpression: String, + data: Map = emptyMap(), + ) + + /** + * Schedule [handlerKey] to run exactly once at [runAt]. + */ + public fun scheduleOnce( + scheduleKey: String, + handlerKey: String, + runAt: Instant, + data: Map = emptyMap(), + ) + + /** + * Remove the schedule identified by [scheduleKey]. Returns true + * if a schedule was removed, false if none existed. + */ + public fun unschedule(scheduleKey: String): Boolean + + /** + * Fire the handler immediately on the caller's thread, bypassing + * the persistent job store. Returns the [JobContext] that the + * handler saw (minus the mutable data, which is returned as a + * copy). Throwing propagates back to the caller — the framework + * does NOT swallow the exception into a Quartz MISFIRE for + * `triggerNow` because the caller (usually an HTTP request) + * expects to see the error as a 400/500 response. + */ + public fun triggerNow( + handlerKey: String, + data: Map = emptyMap(), + ): JobExecutionSummary + + /** + * List every currently active schedule across every handler. + * Useful for the HTTP surface and for tests. + */ + public fun listScheduled(): List +} + +/** + * Read-only result of a manual [JobScheduler.triggerNow] call. + */ +public data class JobExecutionSummary( + public val handlerKey: String, + public val correlationId: String, + public val startedAt: Instant, + public val finishedAt: Instant, + public val ok: Boolean, +) + +/** + * Read-only snapshot of a currently scheduled job. Returned by + * [JobScheduler.listScheduled] and by the HTTP inspection endpoint. + */ +public data class ScheduledJobInfo( + public val scheduleKey: String, + public val handlerKey: String, + public val kind: ScheduleKind, + public val cronExpression: String?, + public val nextFireTime: Instant?, + public val previousFireTime: Instant?, + public val data: Map, +) + +/** + * Discriminator for [ScheduledJobInfo.kind]. Mirrors the + * [JobScheduler.scheduleCron] / [JobScheduler.scheduleOnce] + * distinction. + */ +public enum class ScheduleKind { + CRON, + ONCE, +} diff --git a/distribution/build.gradle.kts b/distribution/build.gradle.kts index b7d2d7b..e34067c 100644 --- a/distribution/build.gradle.kts +++ b/distribution/build.gradle.kts @@ -27,6 +27,7 @@ dependencies { implementation(project(":platform:platform-metadata")) implementation(project(":platform:platform-i18n")) implementation(project(":platform:platform-workflow")) + implementation(project(":platform:platform-jobs")) implementation(project(":pbc:pbc-identity")) implementation(project(":pbc:pbc-catalog")) implementation(project(":pbc:pbc-partners")) diff --git a/distribution/src/main/resources/application.yaml b/distribution/src/main/resources/application.yaml index 157cbae..a8c38d2 100644 --- a/distribution/src/main/resources/application.yaml +++ b/distribution/src/main/resources/application.yaml @@ -45,8 +45,8 @@ spring: # classpath*:/processes/*.bpmn20.xml on boot. flowable: database-schema-update: true - # Disable async job executor for now — the framework's background-job - # story lives in the future Quartz integration (P1.10), not in Flowable. + # Disable async job executor — the framework's background-job story + # lives in Quartz (platform-jobs), not in Flowable. async-executor-activate: false process: servlet: @@ -54,6 +54,31 @@ flowable: # Flowable's built-in REST endpoints are off. enabled: false +# Quartz scheduler (platform-jobs, P1.10). Persistent JDBC job store +# against the host Postgres so cron + one-shot schedules survive +# restarts. Spring Boot's QuartzDataSourceScriptDatabaseInitializer +# runs the QRTZ_* DDL on first boot and skips on subsequent boots +# (it checks for an existing QRTZ_LOCKS table first), which +# coexists peacefully with our Liquibase-owned schema in the same +# way Flowable's ACT_* tables do. +spring.quartz: + job-store-type: jdbc + jdbc: + initialize-schema: always + # Spring Boot's Quartz starter wires the JobStore class and the + # DataSource automatically when job-store-type=jdbc. DO NOT set + # `org.quartz.jobStore.class` in the properties below — that + # overrides Spring Boot's `LocalDataSourceJobStore` with + # `JobStoreTX`, which then throws "DataSource name not set" at + # scheduler init because Quartz-standalone expects a `dataSource` + # property that Spring Boot doesn't provide. + properties: + org.quartz.scheduler.instanceName: vibeerp-scheduler + org.quartz.scheduler.instanceId: AUTO + org.quartz.threadPool.threadCount: "4" + org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate + org.quartz.jobStore.isClustered: "false" + server: port: 8080 shutdown: graceful diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 192be2e..1737684 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -54,6 +54,9 @@ pf4j-spring = { module = "org.pf4j:pf4j-spring", version = "0.9.0" } # Workflow (BPMN 2.0 process engine) flowable-spring-boot-starter-process = { module = "org.flowable:flowable-spring-boot-starter-process", version.ref = "flowable" } +# Job scheduler (Quartz via Spring Boot starter) +spring-boot-starter-quartz = { module = "org.springframework.boot:spring-boot-starter-quartz", version.ref = "springBoot" } + # i18n icu4j = { module = "com.ibm.icu:icu4j", version.ref = "icu4j" } diff --git a/platform/platform-jobs/build.gradle.kts b/platform/platform-jobs/build.gradle.kts new file mode 100644 index 0000000..6ee67a0 --- /dev/null +++ b/platform/platform-jobs/build.gradle.kts @@ -0,0 +1,48 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.spring) + alias(libs.plugins.spring.dependency.management) +} + +description = "vibe_erp Quartz-backed job scheduler. Adapts Quartz to the api.v1 JobHandler + " + + "JobScheduler contracts so plug-ins and PBCs never import Quartz types. INTERNAL." + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +kotlin { + jvmToolchain(21) + compilerOptions { + freeCompilerArgs.add("-Xjsr305=strict") + } +} + +// The only module that pulls Quartz in. Everything else in the +// framework interacts with scheduled work through the api.v1 +// JobHandler + JobScheduler + JobContext contract, never through +// Quartz types. Guardrail #10: api.v1 never leaks Quartz. +dependencies { + api(project(":api:api-v1")) + implementation(project(":platform:platform-persistence")) // PrincipalContext.runAs for scheduled jobs + implementation(project(":platform:platform-security")) // @RequirePermission on the controller + + implementation(libs.kotlin.stdlib) + implementation(libs.kotlin.reflect) + implementation(libs.jackson.module.kotlin) + + implementation(libs.spring.boot.starter) + implementation(libs.spring.boot.starter.web) + implementation(libs.spring.boot.starter.quartz) + + testImplementation(libs.spring.boot.starter.test) + testImplementation(libs.junit.jupiter) + testImplementation(libs.assertk) + testImplementation(libs.mockk) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/platform/platform-jobs/src/main/kotlin/org/vibeerp/platform/jobs/JobHandlerRegistry.kt b/platform/platform-jobs/src/main/kotlin/org/vibeerp/platform/jobs/JobHandlerRegistry.kt new file mode 100644 index 0000000..6600825 --- /dev/null +++ b/platform/platform-jobs/src/main/kotlin/org/vibeerp/platform/jobs/JobHandlerRegistry.kt @@ -0,0 +1,100 @@ +package org.vibeerp.platform.jobs + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.vibeerp.api.v1.jobs.JobHandler +import java.util.concurrent.ConcurrentHashMap + +/** + * The host-side index of every [JobHandler] currently known to the + * framework, keyed by [JobHandler.key]. + * + * Parallel to `org.vibeerp.platform.workflow.TaskHandlerRegistry` — + * same owner-tagged design so plug-ins can register and unregister + * handlers atomically per plug-in lifecycle, same fail-fast on + * duplicate keys, same `find(key)` hot path. + * + * Population lifecycle: + * - At Spring refresh: every `@Component` bean that implements + * [JobHandler] is constructor-injected as a `List` + * and registered with owner [OWNER_CORE]. + * - At plug-in start: the (future) plug-in-loader hook calls + * [register] with the plug-in id. The seam is defined now even + * though the loader integration lands when a plug-in actually + * ships a job handler. + * - At plug-in stop: [unregisterAllByOwner] strips every handler + * contributed by a plug-in in one call. + * + * Thread-safety: the registry is backed by a [ConcurrentHashMap]. + * Reads happen from the Quartz execution thread on every job run, + * writes happen only during boot and plug-in lifecycle events. + */ +@Component +class JobHandlerRegistry( + initialHandlers: List = emptyList(), +) { + private val log = LoggerFactory.getLogger(JobHandlerRegistry::class.java) + private val handlers: ConcurrentHashMap = ConcurrentHashMap() + + init { + initialHandlers.forEach { register(it, OWNER_CORE) } + log.info( + "JobHandlerRegistry initialised with {} core JobHandler bean(s): {}", + handlers.size, + handlers.keys.sorted(), + ) + } + + fun register(handler: JobHandler, ownerId: String = OWNER_CORE) { + val key = handler.key() + require(key.isNotBlank()) { + "JobHandler.key() must not be blank (offender: ${handler.javaClass.name})" + } + require(ownerId.isNotBlank()) { + "JobHandlerRegistry.register ownerId must not be blank" + } + val entry = Entry(handler, ownerId) + val existing = handlers.putIfAbsent(key, entry) + check(existing == null) { + "duplicate JobHandler key '$key' — already registered by " + + "${existing?.handler?.javaClass?.name} (owner='${existing?.ownerId}'), " + + "attempted to register ${handler.javaClass.name} (owner='$ownerId')" + } + log.info("registered JobHandler '{}' owner='{}' class='{}'", key, ownerId, handler.javaClass.name) + } + + fun unregister(key: String): Boolean { + val removed = handlers.remove(key) ?: return false + log.info("unregistered JobHandler '{}' owner='{}'", key, removed.ownerId) + return true + } + + fun unregisterAllByOwner(ownerId: String): Int { + var removed = 0 + val iterator = handlers.entries.iterator() + while (iterator.hasNext()) { + val (key, entry) = iterator.next() + if (entry.ownerId == ownerId) { + iterator.remove() + removed += 1 + log.info("unregistered JobHandler '{}' (owner stopped)", key) + } + } + if (removed > 0) { + log.info("JobHandlerRegistry.unregisterAllByOwner('{}') removed {} handler(s)", ownerId, removed) + } + return removed + } + + fun find(key: String): JobHandler? = handlers[key]?.handler + + fun keys(): Set = handlers.keys.toSet() + + fun size(): Int = handlers.size + + companion object { + const val OWNER_CORE: String = "core" + } + + private data class Entry(val handler: JobHandler, val ownerId: String) +} diff --git a/platform/platform-jobs/src/main/kotlin/org/vibeerp/platform/jobs/QuartzJobBridge.kt b/platform/platform-jobs/src/main/kotlin/org/vibeerp/platform/jobs/QuartzJobBridge.kt new file mode 100644 index 0000000..7855f65 --- /dev/null +++ b/platform/platform-jobs/src/main/kotlin/org/vibeerp/platform/jobs/QuartzJobBridge.kt @@ -0,0 +1,133 @@ +package org.vibeerp.platform.jobs + +import org.quartz.DisallowConcurrentExecution +import org.quartz.JobDataMap +import org.quartz.JobExecutionContext +import org.quartz.JobExecutionException +import org.quartz.PersistJobDataAfterExecution +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.vibeerp.api.v1.core.Id +import org.vibeerp.api.v1.security.Principal +import org.vibeerp.platform.persistence.security.PrincipalContext +import java.util.Locale +import java.util.UUID + +/** + * The single Quartz-facing bridge: ONE `org.quartz.Job` implementation + * that every persistent trigger in the scheduler invokes. Analogous to + * `org.vibeerp.platform.workflow.DispatchingJavaDelegate` for the + * workflow engine: one shared bean, routing by key. + * + * **Routing.** The handler key lives in the Quartz `JobDataMap` under + * [KEY_HANDLER_KEY]. On every invocation the bridge reads the key, + * looks it up in the [JobHandlerRegistry], and executes the matching + * handler inside a `PrincipalContext.runAs("system:jobs:")` + * block so the audit columns written by anything the handler + * touches carry a structured, greppable value ("system:jobs:core.audit.prune"). + * + * **Why the handler runs inside `runAs`.** PrincipalContext is a + * ThreadLocal populated by `PrincipalContextFilter` for HTTP + * requests; Quartz's worker threads have no such filter. Without the + * explicit runAs wrapper, any JPA write the handler performs would + * record `__system__` (the fallback) which is fine but loses the + * "which job wrote this row?" signal. Using `jobs:` threads + * the source through every audit row. + * + * **@DisallowConcurrentExecution** guarantees that a long-running + * job cannot be started again while the previous invocation is + * still in flight. Most scheduled jobs should not overlap with + * themselves. Handlers that DO want concurrent execution can + * document it later with a different bridge subclass. + * + * **@PersistJobDataAfterExecution** keeps any updates to the + * JobDataMap across executions — the framework doesn't currently + * support mutating data in the handler (JobContext is read-only by + * design), but the annotation is cheap and leaves the door open. + * + * **Why @Autowired field injection** (not constructor injection): + * Quartz instantiates `Job` classes through its own + * `JobFactory.newJob(...)`. Spring Boot's Quartz auto-configuration + * wires up a `SpringBeanJobFactory` that autowires bean fields + * after construction, but does NOT call a constructor with Spring + * beans. Field injection is the documented pattern for + * Quartz-instantiated jobs in a Spring Boot app. + */ +@PersistJobDataAfterExecution +@DisallowConcurrentExecution +class QuartzJobBridge : org.quartz.Job { + + @Autowired + private lateinit var registry: JobHandlerRegistry + + private val log = LoggerFactory.getLogger(QuartzJobBridge::class.java) + + override fun execute(execution: JobExecutionContext) { + val data: JobDataMap = execution.mergedJobDataMap + val handlerKey = data.getString(KEY_HANDLER_KEY) + ?: throw JobExecutionException( + "QuartzJobBridge fired without '$KEY_HANDLER_KEY' in the JobDataMap " + + "(triggerKey=${execution.trigger.key})", + ) + + val handler = registry.find(handlerKey) + ?: throw JobExecutionException( + "no JobHandler registered for key '$handlerKey' " + + "(triggerKey=${execution.trigger.key}). Known keys: ${registry.keys().sorted()}", + ) + + val ctx = SimpleJobContext( + principal = Principal.System( + id = Id(SCHEDULED_JOB_PRINCIPAL_ID), + name = "jobs:$handlerKey", + ), + locale = Locale.ROOT, + data = jobDataMapToMap(data), + ) + + log.info( + "QuartzJobBridge firing handler='{}' trigger='{}' corr='{}'", + handlerKey, execution.trigger.key, ctx.correlationId(), + ) + + PrincipalContext.runAs("system:jobs:$handlerKey") { + try { + handler.execute(ctx) + } catch (ex: Throwable) { + log.error( + "JobHandler '{}' threw during execute — Quartz will mark this trigger as MISFIRED", + handlerKey, ex, + ) + // Re-wrap as JobExecutionException so Quartz's + // misfire/retry machinery handles it properly. + throw JobExecutionException(ex, /* refireImmediately = */ false) + } + } + } + + private fun jobDataMapToMap(data: JobDataMap): Map { + // Strip the internal routing key — handler code must not + // depend on it being in JobContext.data. + return data.wrappedMap + .filterKeys { it != KEY_HANDLER_KEY } + .mapKeys { it.key.toString() } + } + + companion object { + /** + * JobDataMap key the bridge reads to find the target handler + * key. Constant so the scheduler implementation that puts + * the value in uses the same name as the bridge that reads + * it out. + */ + const val KEY_HANDLER_KEY: String = "__vibeerp_handler_key" + + /** + * Stable UUID identifying "the scheduler" as a principal. + * Appears in audit rows written by scheduled jobs as + * `created_by='system:jobs:'`. + */ + private val SCHEDULED_JOB_PRINCIPAL_ID: UUID = + UUID.fromString("00000000-0000-0000-0000-0000000f10b5") + } +} diff --git a/platform/platform-jobs/src/main/kotlin/org/vibeerp/platform/jobs/QuartzJobScheduler.kt b/platform/platform-jobs/src/main/kotlin/org/vibeerp/platform/jobs/QuartzJobScheduler.kt new file mode 100644 index 0000000..367eda1 --- /dev/null +++ b/platform/platform-jobs/src/main/kotlin/org/vibeerp/platform/jobs/QuartzJobScheduler.kt @@ -0,0 +1,275 @@ +package org.vibeerp.platform.jobs + +import org.quartz.CronExpression +import org.quartz.CronScheduleBuilder +import org.quartz.JobBuilder +import org.quartz.JobDataMap +import org.quartz.JobKey +import org.quartz.Scheduler +import org.quartz.SimpleScheduleBuilder +import org.quartz.Trigger +import org.quartz.TriggerBuilder +import org.quartz.TriggerKey +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.vibeerp.api.v1.core.Id +import org.vibeerp.api.v1.jobs.JobExecutionSummary +import org.vibeerp.api.v1.jobs.JobScheduler +import org.vibeerp.api.v1.jobs.ScheduleKind +import org.vibeerp.api.v1.jobs.ScheduledJobInfo +import org.vibeerp.api.v1.security.Principal +import org.vibeerp.platform.security.authz.AuthorizationContext +import java.time.Instant +import java.util.Date +import java.util.Locale +import java.util.UUID + +/** + * The concrete api.v1 [JobScheduler] implementation. Everything here + * is vibe_erp-internal; plug-ins never import this class — they + * inject the api.v1 interface and the Spring context hands them this + * bean. + * + * **Scheduling model.** Quartz's "job + trigger" split is + * deliberately hidden from the api.v1 contract: the framework + * exposes only "schedules" which the caller identifies by a single + * [scheduleKey]. Internally each schedule maps to one Quartz + * `JobDetail` + one `Trigger`, both keyed by `scheduleKey` under a + * fixed group name [JOB_GROUP]. The detail's job class is always + * [QuartzJobBridge]; the real handler key lives in the JobDataMap + * under [QuartzJobBridge.KEY_HANDLER_KEY]. + * + * **Why every schedule has its own JobDetail** (rather than sharing + * one JobDetail per handler and pointing many triggers at it): + * the JobDataMap is attached to the JobDetail, and each schedule + * needs its own data payload. Shared job details would mean all + * triggers for the same handler saw the same data, which breaks + * the "same handler, two different cron schedules, different + * arguments" use case. + * + * **Persistence.** The Spring Boot Quartz starter is configured + * with `spring.quartz.job-store-type=jdbc` against the host + * Postgres, so schedules survive restarts. The first boot creates + * the QRTZ_* tables via `spring.quartz.jdbc.initialize-schema=always` + * (idempotent — Spring Boot's `QuartzDataSourceScriptDatabaseInitializer` + * skips if the tables already exist). + */ +@Service +class QuartzJobScheduler( + private val scheduler: Scheduler, + private val registry: JobHandlerRegistry, +) : JobScheduler { + + private val log = LoggerFactory.getLogger(QuartzJobScheduler::class.java) + + override fun scheduleCron( + scheduleKey: String, + handlerKey: String, + cronExpression: String, + data: Map, + ) { + require(scheduleKey.isNotBlank()) { "scheduleKey must not be blank" } + require(handlerKey.isNotBlank()) { "handlerKey must not be blank" } + require(cronExpression.isNotBlank()) { "cronExpression must not be blank" } + requireHandlerRegistered(handlerKey) + require(CronExpression.isValidExpression(cronExpression)) { + "invalid Quartz cron expression: '$cronExpression'" + } + + val jobKey = jobKey(scheduleKey) + val triggerKey = triggerKey(scheduleKey) + + val jobDetail = JobBuilder.newJob(QuartzJobBridge::class.java) + .withIdentity(jobKey) + .usingJobData(buildJobData(handlerKey, data)) + .storeDurably(true) + .build() + + val trigger: Trigger = TriggerBuilder.newTrigger() + .withIdentity(triggerKey) + .forJob(jobKey) + .withSchedule( + CronScheduleBuilder.cronSchedule(cronExpression) + .withMisfireHandlingInstructionDoNothing(), + ) + .build() + + // addJob(..., replace = true) is idempotent on the JobKey; + // scheduleJob(...) is NOT idempotent on the TriggerKey, so + // we explicitly unschedule first. + scheduler.addJob(jobDetail, /* replace = */ true, /* storeNonDurableWhileAwaitingScheduling = */ true) + if (scheduler.checkExists(triggerKey)) { + scheduler.rescheduleJob(triggerKey, trigger) + } else { + scheduler.scheduleJob(trigger) + } + log.info("scheduled CRON '{}' -> handler '{}' cron='{}'", scheduleKey, handlerKey, cronExpression) + } + + override fun scheduleOnce( + scheduleKey: String, + handlerKey: String, + runAt: Instant, + data: Map, + ) { + require(scheduleKey.isNotBlank()) { "scheduleKey must not be blank" } + require(handlerKey.isNotBlank()) { "handlerKey must not be blank" } + requireHandlerRegistered(handlerKey) + + val jobKey = jobKey(scheduleKey) + val triggerKey = triggerKey(scheduleKey) + + val jobDetail = JobBuilder.newJob(QuartzJobBridge::class.java) + .withIdentity(jobKey) + .usingJobData(buildJobData(handlerKey, data)) + .storeDurably(true) + .build() + + val trigger: Trigger = TriggerBuilder.newTrigger() + .withIdentity(triggerKey) + .forJob(jobKey) + .startAt(Date.from(runAt)) + .withSchedule( + SimpleScheduleBuilder.simpleSchedule() + .withMisfireHandlingInstructionFireNow(), + ) + .build() + + scheduler.addJob(jobDetail, true, true) + if (scheduler.checkExists(triggerKey)) { + scheduler.rescheduleJob(triggerKey, trigger) + } else { + scheduler.scheduleJob(trigger) + } + log.info("scheduled ONCE '{}' -> handler '{}' runAt={}", scheduleKey, handlerKey, runAt) + } + + override fun unschedule(scheduleKey: String): Boolean { + val jobKey = jobKey(scheduleKey) + val existed = scheduler.checkExists(jobKey) + if (existed) { + scheduler.deleteJob(jobKey) + log.info("unscheduled '{}'", scheduleKey) + } + return existed + } + + override fun triggerNow( + handlerKey: String, + data: Map, + ): JobExecutionSummary { + require(handlerKey.isNotBlank()) { "handlerKey must not be blank" } + val handler = registry.find(handlerKey) + ?: throw IllegalArgumentException("no JobHandler registered for key '$handlerKey'") + + val principal = callerPrincipal() + val ctx = SimpleJobContext( + principal = principal, + locale = Locale.ROOT, + data = data, + ) + val startedAt = ctx.startedAt() + val correlationId = ctx.correlationId() + + log.info( + "triggerNow handler='{}' corr='{}' principal='{}'", + handlerKey, correlationId, principal.javaClass.simpleName, + ) + + var ok = false + try { + handler.execute(ctx) + ok = true + } finally { + log.info( + "triggerNow handler='{}' corr='{}' ok={}", + handlerKey, correlationId, ok, + ) + } + return JobExecutionSummary( + handlerKey = handlerKey, + correlationId = correlationId, + startedAt = startedAt, + finishedAt = Instant.now(), + ok = ok, + ) + } + + override fun listScheduled(): List { + val keys = scheduler.getTriggerKeys( + org.quartz.impl.matchers.GroupMatcher.triggerGroupEquals(TRIGGER_GROUP), + ) + return keys.mapNotNull { tKey -> + val trigger = scheduler.getTrigger(tKey) ?: return@mapNotNull null + val jobDetail = scheduler.getJobDetail(trigger.jobKey) ?: return@mapNotNull null + val handlerKey = jobDetail.jobDataMap.getString(QuartzJobBridge.KEY_HANDLER_KEY) ?: "" + val (kind, cron) = when (trigger) { + is org.quartz.CronTrigger -> ScheduleKind.CRON to trigger.cronExpression + else -> ScheduleKind.ONCE to null + } + ScheduledJobInfo( + scheduleKey = tKey.name, + handlerKey = handlerKey, + kind = kind, + cronExpression = cron, + nextFireTime = trigger.nextFireTime?.toInstant(), + previousFireTime = trigger.previousFireTime?.toInstant(), + data = jobDetail.jobDataMap.wrappedMap + .filterKeys { it != QuartzJobBridge.KEY_HANDLER_KEY } + .mapKeys { it.key.toString() }, + ) + } + } + + // ─── internals ───────────────────────────────────────────────── + + private fun requireHandlerRegistered(handlerKey: String) { + require(registry.find(handlerKey) != null) { + "no JobHandler registered for key '$handlerKey' " + + "(known keys: ${registry.keys().sorted()})" + } + } + + private fun jobKey(scheduleKey: String): JobKey = JobKey.jobKey(scheduleKey, JOB_GROUP) + private fun triggerKey(scheduleKey: String): TriggerKey = TriggerKey.triggerKey(scheduleKey, TRIGGER_GROUP) + + private fun buildJobData(handlerKey: String, data: Map): JobDataMap { + val map = JobDataMap() + map[QuartzJobBridge.KEY_HANDLER_KEY] = handlerKey + for ((k, v) in data) { + if (k == QuartzJobBridge.KEY_HANDLER_KEY) continue + // Quartz's JobDataMap only persists Java-serializable + // values when using the JDBC store. Stringify anything + // that isn't a String/Number/Boolean/null to stay safe. + map[k] = when (v) { + null -> null + is String, is Number, is Boolean -> v + else -> v.toString() + } + } + return map + } + + private fun callerPrincipal(): Principal { + val current = AuthorizationContext.current() + return if (current != null) { + Principal.User( + id = Id( + runCatching { UUID.fromString(current.id) } + .getOrElse { UUID.nameUUIDFromBytes(current.id.toByteArray()) }, + ), + username = current.username, + ) + } else { + Principal.System( + id = Id(UUID.fromString("00000000-0000-0000-0000-0000000f10b5")), + name = "jobs:manual-trigger", + ) + } + } + + companion object { + const val JOB_GROUP: String = "vibeerp-jobs" + const val TRIGGER_GROUP: String = "vibeerp-jobs" + } +} diff --git a/platform/platform-jobs/src/main/kotlin/org/vibeerp/platform/jobs/SimpleJobContext.kt b/platform/platform-jobs/src/main/kotlin/org/vibeerp/platform/jobs/SimpleJobContext.kt new file mode 100644 index 0000000..b866847 --- /dev/null +++ b/platform/platform-jobs/src/main/kotlin/org/vibeerp/platform/jobs/SimpleJobContext.kt @@ -0,0 +1,32 @@ +package org.vibeerp.platform.jobs + +import org.vibeerp.api.v1.jobs.JobContext +import org.vibeerp.api.v1.security.Principal +import java.time.Instant +import java.util.Locale +import java.util.UUID + +/** + * Internal immutable implementation of api.v1 [JobContext]. Plug-ins + * never see this class — they only see the interface — and the + * Quartz bridge constructs a fresh instance for every job execution. + * + * Defensive copy of [data] happens at construction time so a handler + * can't mutate the caller's original map (and vice versa). + */ +internal class SimpleJobContext( + private val principal: Principal, + private val locale: Locale, + private val correlationId: String = UUID.randomUUID().toString(), + private val startedAt: Instant = Instant.now(), + data: Map = emptyMap(), +) : JobContext { + + private val snapshot: Map = data.toMap() + + override fun principal(): Principal = principal + override fun locale(): Locale = locale + override fun correlationId(): String = correlationId + override fun startedAt(): Instant = startedAt + override fun data(): Map = snapshot +} diff --git a/platform/platform-jobs/src/main/kotlin/org/vibeerp/platform/jobs/builtin/VibeErpPingJobHandler.kt b/platform/platform-jobs/src/main/kotlin/org/vibeerp/platform/jobs/builtin/VibeErpPingJobHandler.kt new file mode 100644 index 0000000..57c5494 --- /dev/null +++ b/platform/platform-jobs/src/main/kotlin/org/vibeerp/platform/jobs/builtin/VibeErpPingJobHandler.kt @@ -0,0 +1,47 @@ +package org.vibeerp.platform.jobs.builtin + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.vibeerp.api.v1.jobs.JobContext +import org.vibeerp.api.v1.jobs.JobHandler +import org.vibeerp.api.v1.security.Principal +import java.time.Instant + +/** + * Built-in diagnostic job handler. Mirrors `PingTaskHandler` from + * platform-workflow: a trivial self-test the operator can fire via + * `POST /api/v1/jobs/handlers/vibeerp.jobs.ping/trigger` to prove + * that the Quartz engine, the JobHandlerRegistry, and the + * QuartzJobBridge are all wired end-to-end without deploying a + * real cron job. + * + * The handler logs the invocation (principal label + correlation id + * + data payload) and exits. No side effects. Safe to trigger from + * any environment. + */ +@Component +class VibeErpPingJobHandler : JobHandler { + + private val log = LoggerFactory.getLogger(VibeErpPingJobHandler::class.java) + + override fun key(): String = KEY + + override fun execute(context: JobContext) { + val principalLabel = when (val p = context.principal()) { + is Principal.User -> "user:${p.username}" + is Principal.System -> "system:${p.name}" + is Principal.PluginPrincipal -> "plugin:${p.pluginId}" + } + log.info( + "VibeErpPingJobHandler invoked at={} principal='{}' corr='{}' data={}", + Instant.now(), + principalLabel, + context.correlationId(), + context.data(), + ) + } + + companion object { + const val KEY: String = "vibeerp.jobs.ping" + } +} diff --git a/platform/platform-jobs/src/main/kotlin/org/vibeerp/platform/jobs/http/JobController.kt b/platform/platform-jobs/src/main/kotlin/org/vibeerp/platform/jobs/http/JobController.kt new file mode 100644 index 0000000..6d6b460 --- /dev/null +++ b/platform/platform-jobs/src/main/kotlin/org/vibeerp/platform/jobs/http/JobController.kt @@ -0,0 +1,136 @@ +package org.vibeerp.platform.jobs.http + +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.vibeerp.api.v1.jobs.JobExecutionSummary +import org.vibeerp.api.v1.jobs.JobScheduler +import org.vibeerp.api.v1.jobs.ScheduledJobInfo +import org.vibeerp.platform.jobs.JobHandlerRegistry +import org.vibeerp.platform.security.authz.RequirePermission +import java.time.Instant + +/** + * Minimal HTTP surface for the framework's job scheduler. + * + * - `GET /api/v1/jobs/handlers` list registered JobHandler keys + * - `POST /api/v1/jobs/handlers/{key}/trigger` fire a handler NOW (manual) + * - `GET /api/v1/jobs/scheduled` list currently scheduled jobs + * - `POST /api/v1/jobs/scheduled` schedule a cron or one-shot job + * - `DELETE /api/v1/jobs/scheduled/{key}` unschedule a job + * + * Permission-gated the same way every other controller is, via + * [org.vibeerp.platform.security.authz.RequirePermission]. + */ +@RestController +@RequestMapping("/api/v1/jobs") +class JobController( + private val jobScheduler: JobScheduler, + private val handlerRegistry: JobHandlerRegistry, +) { + + @GetMapping("/handlers") + @RequirePermission("jobs.handler.read") + fun listHandlers(): HandlersResponse = HandlersResponse( + count = handlerRegistry.size(), + keys = handlerRegistry.keys().sorted(), + ) + + @PostMapping("/handlers/{key}/trigger") + @RequirePermission("jobs.job.trigger") + fun trigger( + @PathVariable key: String, + @RequestBody(required = false) request: TriggerRequest?, + ): ResponseEntity { + val summary = jobScheduler.triggerNow(key, request?.data ?: emptyMap()) + return ResponseEntity.status(HttpStatus.OK).body(summary) + } + + @GetMapping("/scheduled") + @RequirePermission("jobs.schedule.read") + fun listScheduled(): List = jobScheduler.listScheduled() + + @PostMapping("/scheduled") + @RequirePermission("jobs.schedule.write") + fun schedule(@RequestBody request: ScheduleRequest): ResponseEntity { + when { + request.cronExpression != null -> jobScheduler.scheduleCron( + scheduleKey = request.scheduleKey, + handlerKey = request.handlerKey, + cronExpression = request.cronExpression, + data = request.data ?: emptyMap(), + ) + request.runAt != null -> jobScheduler.scheduleOnce( + scheduleKey = request.scheduleKey, + handlerKey = request.handlerKey, + runAt = request.runAt, + data = request.data ?: emptyMap(), + ) + else -> throw IllegalArgumentException( + "schedule request must include either 'cronExpression' or 'runAt'", + ) + } + return ResponseEntity.status(HttpStatus.CREATED).body( + ScheduledResponse(scheduleKey = request.scheduleKey, handlerKey = request.handlerKey), + ) + } + + @DeleteMapping("/scheduled/{key}") + @RequirePermission("jobs.schedule.write") + fun unschedule(@PathVariable key: String): ResponseEntity { + val removed = jobScheduler.unschedule(key) + return if (removed) { + ResponseEntity.ok(UnscheduleResponse(scheduleKey = key, removed = true)) + } else { + ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(UnscheduleResponse(scheduleKey = key, removed = false)) + } + } + + @ExceptionHandler(IllegalArgumentException::class) + fun handleBadRequest(ex: IllegalArgumentException): ResponseEntity = + ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ErrorResponse(message = ex.message ?: "bad request")) +} + +// ─── DTOs ──────────────────────────────────────────────────────────── + +data class TriggerRequest( + val data: Map? = null, +) + +data class ScheduleRequest( + val scheduleKey: String, + val handlerKey: String, + /** Quartz cron expression. Mutually exclusive with [runAt]. */ + val cronExpression: String? = null, + /** One-shot fire time. Mutually exclusive with [cronExpression]. */ + val runAt: Instant? = null, + val data: Map? = null, +) + +data class HandlersResponse( + val count: Int, + val keys: List, +) + +data class ScheduledResponse( + val scheduleKey: String, + val handlerKey: String, +) + +data class UnscheduleResponse( + val scheduleKey: String, + val removed: Boolean, +) + +data class ErrorResponse( + val message: String, +) diff --git a/platform/platform-jobs/src/main/resources/META-INF/vibe-erp/metadata/jobs.yml b/platform/platform-jobs/src/main/resources/META-INF/vibe-erp/metadata/jobs.yml new file mode 100644 index 0000000..f0d9644 --- /dev/null +++ b/platform/platform-jobs/src/main/resources/META-INF/vibe-erp/metadata/jobs.yml @@ -0,0 +1,25 @@ +# platform-jobs metadata. +# +# Loaded at boot by MetadataLoader, tagged source='core'. + +permissions: + - key: jobs.handler.read + description: List registered JobHandler keys + - key: jobs.job.trigger + description: Manually trigger a registered JobHandler (runs synchronously) + - key: jobs.schedule.read + description: List currently scheduled jobs + - key: jobs.schedule.write + description: Schedule or unschedule jobs (cron + one-shot) + +menus: + - path: /jobs/handlers + label: Job handlers + icon: wrench + section: Jobs + order: 800 + - path: /jobs/scheduled + label: Scheduled jobs + icon: clock + section: Jobs + order: 810 diff --git a/platform/platform-jobs/src/test/kotlin/org/vibeerp/platform/jobs/JobHandlerRegistryTest.kt b/platform/platform-jobs/src/test/kotlin/org/vibeerp/platform/jobs/JobHandlerRegistryTest.kt new file mode 100644 index 0000000..b91b9f1 --- /dev/null +++ b/platform/platform-jobs/src/test/kotlin/org/vibeerp/platform/jobs/JobHandlerRegistryTest.kt @@ -0,0 +1,75 @@ +package org.vibeerp.platform.jobs + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isNull +import org.junit.jupiter.api.Test +import org.vibeerp.api.v1.jobs.JobContext +import org.vibeerp.api.v1.jobs.JobHandler + +class JobHandlerRegistryTest { + + private class FakeHandler(private val k: String) : JobHandler { + override fun key(): String = k + override fun execute(context: JobContext) { /* no-op */ } + } + + @Test + fun `initial handlers are registered with OWNER_CORE`() { + val registry = JobHandlerRegistry(listOf(FakeHandler("a.b.c"), FakeHandler("x.y.z"))) + assertThat(registry.size()).isEqualTo(2) + assertThat(registry.keys()).isEqualTo(setOf("a.b.c", "x.y.z")) + } + + @Test + fun `duplicate key fails fast with both owners in the error`() { + val registry = JobHandlerRegistry() + registry.register(FakeHandler("dup.key"), ownerId = "core") + + assertFailure { registry.register(FakeHandler("dup.key"), ownerId = "printing-shop") } + .isInstanceOf(IllegalStateException::class) + .hasMessage( + "duplicate JobHandler key 'dup.key' — already registered by " + + "org.vibeerp.platform.jobs.JobHandlerRegistryTest\$FakeHandler (owner='core'), " + + "attempted to register org.vibeerp.platform.jobs.JobHandlerRegistryTest\$FakeHandler (owner='printing-shop')", + ) + } + + @Test + fun `unregisterAllByOwner only removes handlers owned by that id`() { + val registry = JobHandlerRegistry() + registry.register(FakeHandler("core.a"), ownerId = "core") + registry.register(FakeHandler("core.b"), ownerId = "core") + registry.register(FakeHandler("plugin.a"), ownerId = "printing-shop") + registry.register(FakeHandler("plugin.b"), ownerId = "printing-shop") + + val removed = registry.unregisterAllByOwner("printing-shop") + assertThat(removed).isEqualTo(2) + assertThat(registry.size()).isEqualTo(2) + assertThat(registry.keys()).isEqualTo(setOf("core.a", "core.b")) + } + + @Test + fun `unregister by key returns false for unknown`() { + val registry = JobHandlerRegistry() + assertThat(registry.unregister("never.seen")).isEqualTo(false) + } + + @Test + fun `find on missing key returns null`() { + val registry = JobHandlerRegistry() + assertThat(registry.find("nope")).isNull() + } + + @Test + fun `blank key is rejected`() { + val registry = JobHandlerRegistry() + assertFailure { registry.register(object : JobHandler { + override fun key(): String = " " + override fun execute(context: JobContext) { } + }) }.isInstanceOf(IllegalArgumentException::class) + } +} diff --git a/platform/platform-jobs/src/test/kotlin/org/vibeerp/platform/jobs/QuartzJobSchedulerTest.kt b/platform/platform-jobs/src/test/kotlin/org/vibeerp/platform/jobs/QuartzJobSchedulerTest.kt new file mode 100644 index 0000000..12cc8c5 --- /dev/null +++ b/platform/platform-jobs/src/test/kotlin/org/vibeerp/platform/jobs/QuartzJobSchedulerTest.kt @@ -0,0 +1,152 @@ +package org.vibeerp.platform.jobs + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isInstanceOf +import assertk.assertions.isTrue +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.Test +import org.quartz.JobBuilder +import org.quartz.JobDetail +import org.quartz.JobKey +import org.quartz.Scheduler +import org.quartz.Trigger +import org.quartz.TriggerKey +import org.vibeerp.api.v1.jobs.JobContext +import org.vibeerp.api.v1.jobs.JobHandler +import java.time.Instant + +class QuartzJobSchedulerTest { + + private class FakeHandler(private val k: String) : JobHandler { + override fun key(): String = k + var executed = 0 + private set + override fun execute(context: JobContext) { + executed += 1 + } + } + + private val scheduler: Scheduler = mockk(relaxed = true) + private val registry = JobHandlerRegistry(listOf(FakeHandler("core.test.ping"))) + private val subject = QuartzJobScheduler(scheduler, registry) + + @Test + fun `scheduleCron rejects an unknown handler key`() { + assertFailure { + subject.scheduleCron( + scheduleKey = "nightly", + handlerKey = "nope", + cronExpression = "0 0 2 * * ?", + ) + }.isInstanceOf(IllegalArgumentException::class) + verify(exactly = 0) { scheduler.scheduleJob(any()) } + } + + @Test + fun `scheduleCron rejects an invalid cron expression`() { + assertFailure { + subject.scheduleCron( + scheduleKey = "nightly", + handlerKey = "core.test.ping", + cronExpression = "not a cron", + ) + }.isInstanceOf(IllegalArgumentException::class) + } + + @Test + fun `scheduleCron adds job + schedules trigger when nothing exists yet`() { + every { scheduler.checkExists(any()) } returns false + + subject.scheduleCron( + scheduleKey = "nightly", + handlerKey = "core.test.ping", + cronExpression = "0 0 2 * * ?", + data = mapOf("retain_days" to 90), + ) + + verify(exactly = 1) { scheduler.addJob(any(), true, true) } + verify(exactly = 1) { scheduler.scheduleJob(any()) } + verify(exactly = 0) { scheduler.rescheduleJob(any(), any()) } + } + + @Test + fun `scheduleCron reschedules when the trigger already exists`() { + every { scheduler.checkExists(any()) } returns true + every { scheduler.rescheduleJob(any(), any()) } returns null + + subject.scheduleCron( + scheduleKey = "nightly", + handlerKey = "core.test.ping", + cronExpression = "0 0 3 * * ?", + ) + + verify(exactly = 1) { scheduler.addJob(any(), true, true) } + verify(exactly = 1) { scheduler.rescheduleJob(any(), any()) } + verify(exactly = 0) { scheduler.scheduleJob(any()) } + } + + @Test + fun `scheduleOnce uses a simple trigger at the requested instant`() { + every { scheduler.checkExists(any()) } returns false + + subject.scheduleOnce( + scheduleKey = "delayed", + handlerKey = "core.test.ping", + runAt = Instant.parse("2026-05-01T00:00:00Z"), + ) + + verify(exactly = 1) { scheduler.addJob(any(), true, true) } + verify(exactly = 1) { scheduler.scheduleJob(any()) } + } + + @Test + fun `unschedule returns true when the job existed, false otherwise`() { + every { scheduler.checkExists(JobKey.jobKey("foo", QuartzJobScheduler.JOB_GROUP)) } returns true + every { scheduler.deleteJob(any()) } returns true + assertThat(subject.unschedule("foo")).isTrue() + + every { scheduler.checkExists(JobKey.jobKey("bar", QuartzJobScheduler.JOB_GROUP)) } returns false + assertThat(subject.unschedule("bar")).isFalse() + } + + @Test + fun `triggerNow calls the handler synchronously and returns ok=true on success`() { + val handler = FakeHandler("core.test.ping.sync") + val registry2 = JobHandlerRegistry(listOf(handler)) + val subj = QuartzJobScheduler(scheduler, registry2) + + val summary = subj.triggerNow("core.test.ping.sync", mapOf("k" to "v")) + + assertThat(handler.executed).isEqualTo(1) + assertThat(summary.handlerKey).isEqualTo("core.test.ping.sync") + assertThat(summary.ok).isTrue() + } + + @Test + fun `triggerNow propagates the handler's exception back to the caller`() { + class BoomHandler : JobHandler { + override fun key() = "core.test.boom" + override fun execute(context: JobContext) { + throw IllegalStateException("kaboom") + } + } + val subj = QuartzJobScheduler(scheduler, JobHandlerRegistry(listOf(BoomHandler()))) + + assertFailure { subj.triggerNow("core.test.boom") } + .isInstanceOf(IllegalStateException::class) + } + + @Test + fun `triggerNow rejects an unknown handler key`() { + assertFailure { subject.triggerNow("nope") } + .isInstanceOf(IllegalArgumentException::class) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index b53b87b..816aceb 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -45,6 +45,9 @@ project(":platform:platform-i18n").projectDir = file("platform/platform-i18n") include(":platform:platform-workflow") project(":platform:platform-workflow").projectDir = file("platform/platform-workflow") +include(":platform:platform-jobs") +project(":platform:platform-jobs").projectDir = file("platform/platform-jobs") + // ─── Packaged Business Capabilities (core PBCs) ───────────────────── include(":pbc:pbc-identity") project(":pbc:pbc-identity").projectDir = file("pbc/pbc-identity")