Commit 5d6a2f100cc1f9b48f128a89bec2931e8e5fb752
1 parent
19e060ec
feat(jobs): P1.10 — Quartz scheduler + api.v1 JobHandler/JobScheduler/JobContext
Closes the P1.10 row of the implementation plan. New platform-jobs
subproject shipping a Quartz-backed background job engine adapted
to the api.v1 JobHandler contract, so PBCs and plug-ins can register
scheduled work without ever importing Quartz types.
## The shape (matches the P2.1 workflow engine)
platform-jobs is to scheduled work what platform-workflow is to
BPMN service tasks. Same pattern, same discipline:
- A single `@Component` bridge (`QuartzJobBridge`) is the ONLY
org.quartz.Job implementation in the framework. Every persistent
trigger points at it.
- A single `JobHandlerRegistry` (owner-tagged, duplicate-key-rejecting,
ConcurrentHashMap-backed) holds every registered JobHandler by key.
Mirrors `TaskHandlerRegistry`.
- The bridge reads the handler key from the trigger's JobDataMap,
looks it up in the registry, and executes the matching JobHandler
inside a `PrincipalContext.runAs("system:jobs:<key>")` block so
audit rows written during the job get a structured, greppable
`created_by` value ("system:jobs:core.audit.prune") instead of
the default `__system__`.
- Handler-thrown exceptions are re-wrapped as `JobExecutionException`
so Quartz's MISFIRE machinery handles them properly.
- `@DisallowConcurrentExecution` on the bridge stops a long-running
handler from being started again before it finishes.
## api.v1 additions (package `org.vibeerp.api.v1.jobs`)
- `JobHandler` — interface with `key()` + `execute(context)`.
Analogous to the workflow TaskHandler. Plug-ins implement this
to contribute scheduled work without any Quartz dependency.
- `JobContext` — read-only execution context passed to the handler:
principal, locale, correlation id, started-at instant, data map.
Unlike TaskContext it has no `set()` writeback — scheduled jobs
don't produce continuation state for a downstream step; a job
that wants to talk to the rest of the system writes to its own
domain table or publishes an event.
- `JobScheduler` — injectable facade exposing:
* `scheduleCron(scheduleKey, handlerKey, cronExpression, data)`
* `scheduleOnce(scheduleKey, handlerKey, runAt, data)`
* `unschedule(scheduleKey): Boolean`
* `triggerNow(handlerKey, data): JobExecutionSummary`
— synchronous in-thread execution, bypasses Quartz; used by
the HTTP trigger endpoint and by tests.
* `listScheduled(): List<ScheduledJobInfo>` — introspection
Both `scheduleCron` and `scheduleOnce` are idempotent on
`scheduleKey` (replace if exists).
- `ScheduledJobInfo` + `JobExecutionSummary` + `ScheduleKind` —
read-only DTOs returned by the scheduler.
## platform-jobs runtime
- `QuartzJobBridge` — the shared Job impl. Routes by the
`__vibeerp_handler_key` JobDataMap entry. Uses `@Autowired` field
injection because Quartz instantiates Job classes through its
own JobFactory (Spring Boot's `SpringBeanJobFactory` autowires
fields after construction, which is the documented pattern).
- `QuartzJobScheduler` — the concrete api.v1 `JobScheduler`
implementation. Builds JobDetail + Trigger pairs under fixed
group names (`vibeerp-jobs`), uses `addJob(replace=true)` +
explicit `checkExists` + `rescheduleJob` for idempotent
scheduling, strips the reserved `__vibeerp_handler_key` from the
data visible to the handler.
- `SimpleJobContext` — internal immutable `JobContext` impl.
Defensive-copies the data map at construction.
- `JobHandlerRegistry` — owner-tagged registry (OWNER_CORE by
default, any other string for plug-in ownership). Same
`register` / `unregister` / `unregisterAllByOwner` / `find` /
`keys` / `size` surface as `TaskHandlerRegistry`. The plug-in
loader integration seam is defined; the loader hook that calls
`register(handler, pluginId)` lands when a plug-in actually ships
a job handler (YAGNI).
- `JobController` at `/api/v1/jobs/**`:
* `GET /handlers` (perm `jobs.handler.read`)
* `POST /handlers/{key}/trigger` (perm `jobs.job.trigger`)
* `GET /scheduled` (perm `jobs.schedule.read`)
* `POST /scheduled` (perm `jobs.schedule.write`)
* `DELETE /scheduled/{key}` (perm `jobs.schedule.write`)
- `VibeErpPingJobHandler` — built-in diagnostic. Key
`vibeerp.jobs.ping`. Logs the invocation and exits. Safe to
trigger from any environment; mirrors the core
`vibeerp.workflow.ping` workflow handler from P2.1.
- `META-INF/vibe-erp/metadata/jobs.yml` — 4 permissions + 2 menus.
## Spring Boot config (application.yaml)
```
spring.quartz:
job-store-type: jdbc
jdbc:
initialize-schema: always # creates QRTZ_* tables on first boot
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"
```
## The config trap caught during smoke-test (documented in-file)
First boot crashed with `SchedulerConfigException: DataSource name
not set.` because I'd initially added
`org.quartz.jobStore.class=org.quartz.impl.jdbcjobstore.JobStoreTX`
to the raw Quartz properties. That is correct for a standalone
Quartz deployment but WRONG for the Spring Boot starter: the
starter configures a `LocalDataSourceJobStore` that wraps the
Spring-managed DataSource automatically when `job-store-type=jdbc`,
and setting `jobStore.class` explicitly overrides that wrapper back
to Quartz's standalone JobStoreTX — which then fails at init
because Quartz-standalone expects a separately-named `dataSource`
property the Spring Boot starter doesn't supply. Fix: drop the
`jobStore.class` property entirely. The `driverDelegateClass` is
still fine to set explicitly because it's read by both the standalone
and Spring-wrapped JobStore implementations. Rationale is documented
in the config comment so the next maintainer doesn't add it back.
## Smoke test (fresh DB, as admin)
```
GET /api/v1/jobs/handlers
→ {"count": 1, "keys": ["vibeerp.jobs.ping"]}
POST /api/v1/jobs/handlers/vibeerp.jobs.ping/trigger
{"data": {"source": "smoke-test"}}
→ 200 {"handlerKey": "vibeerp.jobs.ping",
"correlationId": "e142...",
"startedAt": "...",
"finishedAt": "...",
"ok": true}
log: VibeErpPingJobHandler invoked at=... principal='system:jobs:manual-trigger'
data={source=smoke-test}
GET /api/v1/jobs/scheduled → []
POST /api/v1/jobs/scheduled
{"scheduleKey": "ping-every-sec",
"handlerKey": "vibeerp.jobs.ping",
"cronExpression": "0/1 * * * * ?",
"data": {"trigger": "cron"}}
→ 201 {"scheduleKey": "ping-every-sec", "handlerKey": "vibeerp.jobs.ping"}
# after 3 seconds
GET /api/v1/jobs/scheduled
→ [{"scheduleKey": "ping-every-sec",
"handlerKey": "vibeerp.jobs.ping",
"kind": "CRON",
"cronExpression": "0/1 * * * * ?",
"nextFireTime": "...",
"previousFireTime": "...",
"data": {"trigger": "cron"}}]
DELETE /api/v1/jobs/scheduled/ping-every-sec → 200 {"removed": true}
# handler log count after ~3 seconds of cron ticks
grep -c "VibeErpPingJobHandler invoked" /tmp/boot.log → 5
# 1 manual trigger + 4 cron ticks before unschedule — matches the
# 0/1 * * * * ? expression
# negatives
POST /api/v1/jobs/handlers/nope/trigger
→ 400 "no JobHandler registered for key 'nope'"
POST /api/v1/jobs/scheduled {cronExpression: "not a cron"}
→ 400 "invalid Quartz cron expression: 'not a cron'"
```
## Three schemas coexist in one Postgres database
```
SELECT count(*) FILTER (WHERE table_name LIKE 'qrtz_%') AS quartz_tables,
count(*) FILTER (WHERE table_name LIKE 'act_%') AS flowable_tables,
count(*) FILTER (WHERE table_name NOT LIKE 'qrtz_%'
AND table_name NOT LIKE 'act_%'
AND table_schema = 'public') AS vibeerp_tables
FROM information_schema.tables WHERE table_schema = 'public';
quartz_tables | flowable_tables | vibeerp_tables
---------------+-----------------+----------------
11 | 39 | 48
```
Three independent schema owners (Quartz / Flowable / Liquibase) in
one public schema, no collisions. Spring Boot's
`QuartzDataSourceScriptDatabaseInitializer` runs the QRTZ_* DDL
once and skips on subsequent boots; Flowable's internal MyBatis
schema manager does the same for ACT_* tables; our Liquibase owns
the rest.
## Tests
- 6 new tests in `JobHandlerRegistryTest`:
* initial handlers registered with OWNER_CORE
* duplicate key fails fast with both owners in the error
* unregisterAllByOwner only removes handlers owned by that id
* unregister by key returns false for unknown
* find on missing key returns null
* blank key is rejected
- 9 new tests in `QuartzJobSchedulerTest` (Quartz Scheduler mocked):
* scheduleCron rejects an unknown handler key
* scheduleCron rejects an invalid cron expression
* scheduleCron adds job + schedules trigger when nothing exists yet
* scheduleCron reschedules when the trigger already exists
* scheduleOnce uses a simple trigger at the requested instant
* unschedule returns true/false correctly
* triggerNow calls the handler synchronously and returns ok=true
* triggerNow propagates the handler's exception
* triggerNow rejects an unknown handler key
- Total framework unit tests: 315 (was 300), all green.
## What this unblocks
- **pbc-finance audit prune** — a core recurring job that deletes
posted journal entries older than N days, driven by a cron from
a Tier 1 metadata row.
- **Plug-in scheduled work** — once the loader integration hook is
wired (trivial follow-up), any plug-in's `start(context)` can
register a JobHandler via `context.jobs.register(handler)` and
the host strips it on plug-in stop via `unregisterAllByOwner`.
- **Delayed workflow continuations** — a BPMN handler can call
`jobScheduler.scheduleOnce(...)` to "re-evaluate this workflow
in 24 hours if no one has approved it", bridging the workflow
engine and the scheduler without introducing Thread.sleep.
- **Outbox draining strategy** — the existing 5-second OutboxPoller
can move from a Spring @Scheduled to a Quartz cron so it
inherits the scheduler's persistence, misfire handling, and the
future clustering story.
## Non-goals (parking lot)
- **Clustered scheduling.** `isClustered=false` for now. Making
this true requires every instance to share a unique `instanceId`
and agree on the JDBC lock policy — doable but out of v1.0 scope
since vibe_erp is single-tenant single-instance by design.
- **Async execution of triggerNow.** The current `triggerNow` runs
synchronously on the caller thread so HTTP requests see the real
result. A future "fire and forget" endpoint would delegate to
`Scheduler.triggerJob(...)` against the JobDetail instead.
- **Per-job permissions.** Today the four `jobs.*` permissions gate
the whole controller. A future enhancement could attach
per-handler permissions (so "trigger audit prune" requires a
different permission than "trigger pricing refresh").
- **Plug-in loader integration.** The seam is defined on
`JobHandlerRegistry` (owner tagging + unregisterAllByOwner) but
`VibeErpPluginManager` doesn't call it yet. Lands in the same
chunk as the first plug-in that ships a JobHandler.
Showing
16 changed files
with
1314 additions
and
2 deletions
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/jobs/JobHandler.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.jobs | |
| 2 | + | |
| 3 | +import org.vibeerp.api.v1.security.Principal | |
| 4 | +import java.time.Instant | |
| 5 | +import java.util.Locale | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * Implementation contract for a recurring or one-shot background job. | |
| 9 | + * | |
| 10 | + * A [JobHandler] is to the scheduler what a [org.vibeerp.api.v1.workflow.TaskHandler] | |
| 11 | + * is to the workflow engine: a plug-in (or core PBC) supplies a small | |
| 12 | + * piece of code that runs inside an engine-managed execution context. | |
| 13 | + * The engine owns the when (cron, one-shot, manual trigger), the | |
| 14 | + * transactional boundary, the principal context, and the structured | |
| 15 | + * logging tags; the handler owns the what. | |
| 16 | + * | |
| 17 | + * ``` | |
| 18 | + * @Component | |
| 19 | + * class PruneAuditLogJob : JobHandler { | |
| 20 | + * override fun key() = "core.audit.prune" | |
| 21 | + * override fun execute(context: JobContext) { | |
| 22 | + * // delete audit rows older than 90 days | |
| 23 | + * } | |
| 24 | + * } | |
| 25 | + * ``` | |
| 26 | + * | |
| 27 | + * **Why the handler is decoupled from Quartz** (same rationale as | |
| 28 | + * `TaskHandler` vs Flowable): plug-ins must not import the concrete | |
| 29 | + * scheduler type. The platform adapts Quartz to this api.v1 shape | |
| 30 | + * inside its `platform-jobs` host module. If vibe_erp ever swaps | |
| 31 | + * Quartz for a different scheduler, every handler keeps working | |
| 32 | + * without a single line change. | |
| 33 | + * | |
| 34 | + * **Registration:** | |
| 35 | + * - Core handlers are `@Component` Spring beans and get picked up by | |
| 36 | + * the framework's `JobHandlerRegistry` via constructor injection. | |
| 37 | + * - Plug-in handlers are registered from inside `Plugin.start(context)` | |
| 38 | + * via a future `context.jobs.register(handler)` hook (the seam is | |
| 39 | + * defined, the plug-in loader integration lands when a real | |
| 40 | + * plug-in needs it). | |
| 41 | + * | |
| 42 | + * Duplicate-key rejection at registration time, same discipline as | |
| 43 | + * [org.vibeerp.api.v1.workflow.TaskHandler]. | |
| 44 | + */ | |
| 45 | +public interface JobHandler { | |
| 46 | + | |
| 47 | + /** | |
| 48 | + * Stable, unique identifier for this handler. Convention: | |
| 49 | + * `<pbc-or-plugin-id>.<aggregate>.<verb>`. Used by the scheduler | |
| 50 | + * to route execution to the right handler. Two handlers in the | |
| 51 | + * same running deployment must not return the same key — the | |
| 52 | + * framework fails registration on conflict. | |
| 53 | + */ | |
| 54 | + public fun key(): String | |
| 55 | + | |
| 56 | + /** | |
| 57 | + * Execute the job. The context carries principal, correlation id, | |
| 58 | + * locale, and any job-data the scheduler/trigger passed in at | |
| 59 | + * schedule time. Throwing unwinds the surrounding Spring | |
| 60 | + * transaction (if any) and marks the Quartz trigger as MISFIRED | |
| 61 | + * so the operator sees it in the HTTP surface. | |
| 62 | + */ | |
| 63 | + public fun execute(context: JobContext) | |
| 64 | +} | |
| 65 | + | |
| 66 | +/** | |
| 67 | + * The runtime context for a single [JobHandler.execute] invocation. | |
| 68 | + * | |
| 69 | + * Analogous to `org.vibeerp.api.v1.workflow.TaskContext` but for the | |
| 70 | + * scheduler instead of BPMN. The scheduler host fills in every field | |
| 71 | + * before invoking the handler; the handler is read-only over this | |
| 72 | + * context. | |
| 73 | + * | |
| 74 | + * **Why no `set(name, value)` writeback** (unlike `TaskContext`): | |
| 75 | + * scheduled jobs don't produce continuation state for a downstream | |
| 76 | + * step the way a BPMN service task does. A job that needs to | |
| 77 | + * communicate with the rest of the system writes to its own domain | |
| 78 | + * table or publishes an event via the PBC's `eventBus`. Keeping | |
| 79 | + * [JobContext] read-only makes the handler body trivial to reason | |
| 80 | + * about. | |
| 81 | + */ | |
| 82 | +public interface JobContext { | |
| 83 | + | |
| 84 | + /** | |
| 85 | + * The principal the job runs as. For manually triggered jobs, | |
| 86 | + * this is the authenticated user who hit the "trigger" endpoint. | |
| 87 | + * For scheduled jobs, it's a `Principal.System` whose name is | |
| 88 | + * `jobs:<jobKey>` so audit columns get a structured, greppable | |
| 89 | + * value. | |
| 90 | + */ | |
| 91 | + public fun principal(): Principal | |
| 92 | + | |
| 93 | + /** | |
| 94 | + * The locale to use for any user-facing strings the job produces | |
| 95 | + * (notification titles, log-line i18n, etc.). For a scheduled | |
| 96 | + * job this is the framework default; for a manual trigger it | |
| 97 | + * propagates from the HTTP caller. | |
| 98 | + */ | |
| 99 | + public fun locale(): Locale | |
| 100 | + | |
| 101 | + /** | |
| 102 | + * Correlation id for this job execution. Appears in the | |
| 103 | + * structured logs and the audit log so an operator can trace | |
| 104 | + * a single job run end-to-end. | |
| 105 | + */ | |
| 106 | + public fun correlationId(): String | |
| 107 | + | |
| 108 | + /** | |
| 109 | + * Instant the job actually started running, from the scheduler's | |
| 110 | + * perspective. May differ from the firing time stored on the | |
| 111 | + * trigger when the job was queued behind other work. | |
| 112 | + */ | |
| 113 | + public fun startedAt(): Instant | |
| 114 | + | |
| 115 | + /** | |
| 116 | + * Read-only view of the string-keyed data the trigger passed to | |
| 117 | + * this execution. The scheduler preserves insertion order; the | |
| 118 | + * handler must not mutate the returned map. | |
| 119 | + */ | |
| 120 | + public fun data(): Map<String, Any?> | |
| 121 | +} | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/jobs/JobScheduler.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.jobs | |
| 2 | + | |
| 3 | +import java.time.Instant | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Cross-PBC facade for the framework's background-job scheduler. | |
| 7 | + * | |
| 8 | + * **The second injectable platform service after `EventBus`.** Any | |
| 9 | + * PBC, platform module, or plug-in that needs to kick off work on a | |
| 10 | + * schedule, on a delay, or on demand injects [JobScheduler] (never a | |
| 11 | + * concrete Quartz `Scheduler`) and uses the methods below. The host | |
| 12 | + * module `platform-jobs` provides the only concrete implementation. | |
| 13 | + * | |
| 14 | + * **Scheduling shapes:** | |
| 15 | + * - [scheduleCron] — cron-driven recurring job (e.g. "every night at 02:00"). | |
| 16 | + * Quartz cron expressions (6 or 7 fields) are accepted as-is. | |
| 17 | + * - [scheduleOnce] — one-shot, fires once at a given instant. Useful | |
| 18 | + * for delayed follow-ups ("approve this request in 24h if no one | |
| 19 | + * has responded") without introducing a sleep-based anti-pattern. | |
| 20 | + * - [triggerNow] — fire the handler immediately, NOT through a | |
| 21 | + * persisted Quartz job. Used by the HTTP trigger endpoint and by | |
| 22 | + * tests. Runs synchronously on the caller's thread so a test | |
| 23 | + * can assert the effect inline. | |
| 24 | + * | |
| 25 | + * **Identifier semantics:** | |
| 26 | + * - The [handlerKey] identifies WHICH handler to run. It must match | |
| 27 | + * a registered [JobHandler.key]. | |
| 28 | + * - The [scheduleKey] identifies WHICH schedule this is. Two | |
| 29 | + * schedules for the same handler are legal — you can have "every | |
| 30 | + * night" AND "every Monday at noon" for the same prune-audit-log | |
| 31 | + * handler. Schedule keys must be unique across the running | |
| 32 | + * scheduler and are the handle used to [unschedule]. | |
| 33 | + * | |
| 34 | + * **Idempotency:** [scheduleCron] and [scheduleOnce] are both | |
| 35 | + * idempotent on [scheduleKey]. Calling with the same key twice | |
| 36 | + * replaces the existing schedule (cron expression, fire time, and | |
| 37 | + * data are all overwritten). Use [unschedule] to remove. | |
| 38 | + */ | |
| 39 | +public interface JobScheduler { | |
| 40 | + | |
| 41 | + /** | |
| 42 | + * Schedule [handlerKey] to run on a recurring [cronExpression]. | |
| 43 | + * If a schedule with [scheduleKey] already exists, it is | |
| 44 | + * replaced. | |
| 45 | + * | |
| 46 | + * @param scheduleKey the operator-facing handle for this schedule. | |
| 47 | + * Convention: `<handlerKey>:<descriptor>` e.g. | |
| 48 | + * `core.audit.prune:nightly`. | |
| 49 | + * @param handlerKey a registered [JobHandler.key]. | |
| 50 | + * @param cronExpression a Quartz cron expression with 6 or 7 fields. | |
| 51 | + * The framework does not translate from Unix cron — Quartz's | |
| 52 | + * syntax is the contract. | |
| 53 | + * @param data free-form key/value pairs passed into [JobContext.data] | |
| 54 | + * when the handler runs. | |
| 55 | + * @throws IllegalArgumentException if [handlerKey] is not | |
| 56 | + * registered, the cron expression is invalid, or any of the | |
| 57 | + * required strings is blank. | |
| 58 | + */ | |
| 59 | + public fun scheduleCron( | |
| 60 | + scheduleKey: String, | |
| 61 | + handlerKey: String, | |
| 62 | + cronExpression: String, | |
| 63 | + data: Map<String, Any?> = emptyMap(), | |
| 64 | + ) | |
| 65 | + | |
| 66 | + /** | |
| 67 | + * Schedule [handlerKey] to run exactly once at [runAt]. | |
| 68 | + */ | |
| 69 | + public fun scheduleOnce( | |
| 70 | + scheduleKey: String, | |
| 71 | + handlerKey: String, | |
| 72 | + runAt: Instant, | |
| 73 | + data: Map<String, Any?> = emptyMap(), | |
| 74 | + ) | |
| 75 | + | |
| 76 | + /** | |
| 77 | + * Remove the schedule identified by [scheduleKey]. Returns true | |
| 78 | + * if a schedule was removed, false if none existed. | |
| 79 | + */ | |
| 80 | + public fun unschedule(scheduleKey: String): Boolean | |
| 81 | + | |
| 82 | + /** | |
| 83 | + * Fire the handler immediately on the caller's thread, bypassing | |
| 84 | + * the persistent job store. Returns the [JobContext] that the | |
| 85 | + * handler saw (minus the mutable data, which is returned as a | |
| 86 | + * copy). Throwing propagates back to the caller — the framework | |
| 87 | + * does NOT swallow the exception into a Quartz MISFIRE for | |
| 88 | + * `triggerNow` because the caller (usually an HTTP request) | |
| 89 | + * expects to see the error as a 400/500 response. | |
| 90 | + */ | |
| 91 | + public fun triggerNow( | |
| 92 | + handlerKey: String, | |
| 93 | + data: Map<String, Any?> = emptyMap(), | |
| 94 | + ): JobExecutionSummary | |
| 95 | + | |
| 96 | + /** | |
| 97 | + * List every currently active schedule across every handler. | |
| 98 | + * Useful for the HTTP surface and for tests. | |
| 99 | + */ | |
| 100 | + public fun listScheduled(): List<ScheduledJobInfo> | |
| 101 | +} | |
| 102 | + | |
| 103 | +/** | |
| 104 | + * Read-only result of a manual [JobScheduler.triggerNow] call. | |
| 105 | + */ | |
| 106 | +public data class JobExecutionSummary( | |
| 107 | + public val handlerKey: String, | |
| 108 | + public val correlationId: String, | |
| 109 | + public val startedAt: Instant, | |
| 110 | + public val finishedAt: Instant, | |
| 111 | + public val ok: Boolean, | |
| 112 | +) | |
| 113 | + | |
| 114 | +/** | |
| 115 | + * Read-only snapshot of a currently scheduled job. Returned by | |
| 116 | + * [JobScheduler.listScheduled] and by the HTTP inspection endpoint. | |
| 117 | + */ | |
| 118 | +public data class ScheduledJobInfo( | |
| 119 | + public val scheduleKey: String, | |
| 120 | + public val handlerKey: String, | |
| 121 | + public val kind: ScheduleKind, | |
| 122 | + public val cronExpression: String?, | |
| 123 | + public val nextFireTime: Instant?, | |
| 124 | + public val previousFireTime: Instant?, | |
| 125 | + public val data: Map<String, Any?>, | |
| 126 | +) | |
| 127 | + | |
| 128 | +/** | |
| 129 | + * Discriminator for [ScheduledJobInfo.kind]. Mirrors the | |
| 130 | + * [JobScheduler.scheduleCron] / [JobScheduler.scheduleOnce] | |
| 131 | + * distinction. | |
| 132 | + */ | |
| 133 | +public enum class ScheduleKind { | |
| 134 | + CRON, | |
| 135 | + ONCE, | |
| 136 | +} | ... | ... |
distribution/build.gradle.kts
| ... | ... | @@ -27,6 +27,7 @@ dependencies { |
| 27 | 27 | implementation(project(":platform:platform-metadata")) |
| 28 | 28 | implementation(project(":platform:platform-i18n")) |
| 29 | 29 | implementation(project(":platform:platform-workflow")) |
| 30 | + implementation(project(":platform:platform-jobs")) | |
| 30 | 31 | implementation(project(":pbc:pbc-identity")) |
| 31 | 32 | implementation(project(":pbc:pbc-catalog")) |
| 32 | 33 | implementation(project(":pbc:pbc-partners")) | ... | ... |
distribution/src/main/resources/application.yaml
| ... | ... | @@ -45,8 +45,8 @@ spring: |
| 45 | 45 | # classpath*:/processes/*.bpmn20.xml on boot. |
| 46 | 46 | flowable: |
| 47 | 47 | database-schema-update: true |
| 48 | - # Disable async job executor for now — the framework's background-job | |
| 49 | - # story lives in the future Quartz integration (P1.10), not in Flowable. | |
| 48 | + # Disable async job executor — the framework's background-job story | |
| 49 | + # lives in Quartz (platform-jobs), not in Flowable. | |
| 50 | 50 | async-executor-activate: false |
| 51 | 51 | process: |
| 52 | 52 | servlet: |
| ... | ... | @@ -54,6 +54,31 @@ flowable: |
| 54 | 54 | # Flowable's built-in REST endpoints are off. |
| 55 | 55 | enabled: false |
| 56 | 56 | |
| 57 | +# Quartz scheduler (platform-jobs, P1.10). Persistent JDBC job store | |
| 58 | +# against the host Postgres so cron + one-shot schedules survive | |
| 59 | +# restarts. Spring Boot's QuartzDataSourceScriptDatabaseInitializer | |
| 60 | +# runs the QRTZ_* DDL on first boot and skips on subsequent boots | |
| 61 | +# (it checks for an existing QRTZ_LOCKS table first), which | |
| 62 | +# coexists peacefully with our Liquibase-owned schema in the same | |
| 63 | +# way Flowable's ACT_* tables do. | |
| 64 | +spring.quartz: | |
| 65 | + job-store-type: jdbc | |
| 66 | + jdbc: | |
| 67 | + initialize-schema: always | |
| 68 | + # Spring Boot's Quartz starter wires the JobStore class and the | |
| 69 | + # DataSource automatically when job-store-type=jdbc. DO NOT set | |
| 70 | + # `org.quartz.jobStore.class` in the properties below — that | |
| 71 | + # overrides Spring Boot's `LocalDataSourceJobStore` with | |
| 72 | + # `JobStoreTX`, which then throws "DataSource name not set" at | |
| 73 | + # scheduler init because Quartz-standalone expects a `dataSource` | |
| 74 | + # property that Spring Boot doesn't provide. | |
| 75 | + properties: | |
| 76 | + org.quartz.scheduler.instanceName: vibeerp-scheduler | |
| 77 | + org.quartz.scheduler.instanceId: AUTO | |
| 78 | + org.quartz.threadPool.threadCount: "4" | |
| 79 | + org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate | |
| 80 | + org.quartz.jobStore.isClustered: "false" | |
| 81 | + | |
| 57 | 82 | server: |
| 58 | 83 | port: 8080 |
| 59 | 84 | shutdown: graceful | ... | ... |
gradle/libs.versions.toml
| ... | ... | @@ -54,6 +54,9 @@ pf4j-spring = { module = "org.pf4j:pf4j-spring", version = "0.9.0" } |
| 54 | 54 | # Workflow (BPMN 2.0 process engine) |
| 55 | 55 | flowable-spring-boot-starter-process = { module = "org.flowable:flowable-spring-boot-starter-process", version.ref = "flowable" } |
| 56 | 56 | |
| 57 | +# Job scheduler (Quartz via Spring Boot starter) | |
| 58 | +spring-boot-starter-quartz = { module = "org.springframework.boot:spring-boot-starter-quartz", version.ref = "springBoot" } | |
| 59 | + | |
| 57 | 60 | # i18n |
| 58 | 61 | icu4j = { module = "com.ibm.icu:icu4j", version.ref = "icu4j" } |
| 59 | 62 | ... | ... |
platform/platform-jobs/build.gradle.kts
0 → 100644
| 1 | +plugins { | |
| 2 | + alias(libs.plugins.kotlin.jvm) | |
| 3 | + alias(libs.plugins.kotlin.spring) | |
| 4 | + alias(libs.plugins.spring.dependency.management) | |
| 5 | +} | |
| 6 | + | |
| 7 | +description = "vibe_erp Quartz-backed job scheduler. Adapts Quartz to the api.v1 JobHandler + " + | |
| 8 | + "JobScheduler contracts so plug-ins and PBCs never import Quartz types. INTERNAL." | |
| 9 | + | |
| 10 | +java { | |
| 11 | + toolchain { | |
| 12 | + languageVersion.set(JavaLanguageVersion.of(21)) | |
| 13 | + } | |
| 14 | +} | |
| 15 | + | |
| 16 | +kotlin { | |
| 17 | + jvmToolchain(21) | |
| 18 | + compilerOptions { | |
| 19 | + freeCompilerArgs.add("-Xjsr305=strict") | |
| 20 | + } | |
| 21 | +} | |
| 22 | + | |
| 23 | +// The only module that pulls Quartz in. Everything else in the | |
| 24 | +// framework interacts with scheduled work through the api.v1 | |
| 25 | +// JobHandler + JobScheduler + JobContext contract, never through | |
| 26 | +// Quartz types. Guardrail #10: api.v1 never leaks Quartz. | |
| 27 | +dependencies { | |
| 28 | + api(project(":api:api-v1")) | |
| 29 | + implementation(project(":platform:platform-persistence")) // PrincipalContext.runAs for scheduled jobs | |
| 30 | + implementation(project(":platform:platform-security")) // @RequirePermission on the controller | |
| 31 | + | |
| 32 | + implementation(libs.kotlin.stdlib) | |
| 33 | + implementation(libs.kotlin.reflect) | |
| 34 | + implementation(libs.jackson.module.kotlin) | |
| 35 | + | |
| 36 | + implementation(libs.spring.boot.starter) | |
| 37 | + implementation(libs.spring.boot.starter.web) | |
| 38 | + implementation(libs.spring.boot.starter.quartz) | |
| 39 | + | |
| 40 | + testImplementation(libs.spring.boot.starter.test) | |
| 41 | + testImplementation(libs.junit.jupiter) | |
| 42 | + testImplementation(libs.assertk) | |
| 43 | + testImplementation(libs.mockk) | |
| 44 | +} | |
| 45 | + | |
| 46 | +tasks.test { | |
| 47 | + useJUnitPlatform() | |
| 48 | +} | ... | ... |
platform/platform-jobs/src/main/kotlin/org/vibeerp/platform/jobs/JobHandlerRegistry.kt
0 → 100644
| 1 | +package org.vibeerp.platform.jobs | |
| 2 | + | |
| 3 | +import org.slf4j.LoggerFactory | |
| 4 | +import org.springframework.stereotype.Component | |
| 5 | +import org.vibeerp.api.v1.jobs.JobHandler | |
| 6 | +import java.util.concurrent.ConcurrentHashMap | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * The host-side index of every [JobHandler] currently known to the | |
| 10 | + * framework, keyed by [JobHandler.key]. | |
| 11 | + * | |
| 12 | + * Parallel to `org.vibeerp.platform.workflow.TaskHandlerRegistry` — | |
| 13 | + * same owner-tagged design so plug-ins can register and unregister | |
| 14 | + * handlers atomically per plug-in lifecycle, same fail-fast on | |
| 15 | + * duplicate keys, same `find(key)` hot path. | |
| 16 | + * | |
| 17 | + * Population lifecycle: | |
| 18 | + * - At Spring refresh: every `@Component` bean that implements | |
| 19 | + * [JobHandler] is constructor-injected as a `List<JobHandler>` | |
| 20 | + * and registered with owner [OWNER_CORE]. | |
| 21 | + * - At plug-in start: the (future) plug-in-loader hook calls | |
| 22 | + * [register] with the plug-in id. The seam is defined now even | |
| 23 | + * though the loader integration lands when a plug-in actually | |
| 24 | + * ships a job handler. | |
| 25 | + * - At plug-in stop: [unregisterAllByOwner] strips every handler | |
| 26 | + * contributed by a plug-in in one call. | |
| 27 | + * | |
| 28 | + * Thread-safety: the registry is backed by a [ConcurrentHashMap]. | |
| 29 | + * Reads happen from the Quartz execution thread on every job run, | |
| 30 | + * writes happen only during boot and plug-in lifecycle events. | |
| 31 | + */ | |
| 32 | +@Component | |
| 33 | +class JobHandlerRegistry( | |
| 34 | + initialHandlers: List<JobHandler> = emptyList(), | |
| 35 | +) { | |
| 36 | + private val log = LoggerFactory.getLogger(JobHandlerRegistry::class.java) | |
| 37 | + private val handlers: ConcurrentHashMap<String, Entry> = ConcurrentHashMap() | |
| 38 | + | |
| 39 | + init { | |
| 40 | + initialHandlers.forEach { register(it, OWNER_CORE) } | |
| 41 | + log.info( | |
| 42 | + "JobHandlerRegistry initialised with {} core JobHandler bean(s): {}", | |
| 43 | + handlers.size, | |
| 44 | + handlers.keys.sorted(), | |
| 45 | + ) | |
| 46 | + } | |
| 47 | + | |
| 48 | + fun register(handler: JobHandler, ownerId: String = OWNER_CORE) { | |
| 49 | + val key = handler.key() | |
| 50 | + require(key.isNotBlank()) { | |
| 51 | + "JobHandler.key() must not be blank (offender: ${handler.javaClass.name})" | |
| 52 | + } | |
| 53 | + require(ownerId.isNotBlank()) { | |
| 54 | + "JobHandlerRegistry.register ownerId must not be blank" | |
| 55 | + } | |
| 56 | + val entry = Entry(handler, ownerId) | |
| 57 | + val existing = handlers.putIfAbsent(key, entry) | |
| 58 | + check(existing == null) { | |
| 59 | + "duplicate JobHandler key '$key' — already registered by " + | |
| 60 | + "${existing?.handler?.javaClass?.name} (owner='${existing?.ownerId}'), " + | |
| 61 | + "attempted to register ${handler.javaClass.name} (owner='$ownerId')" | |
| 62 | + } | |
| 63 | + log.info("registered JobHandler '{}' owner='{}' class='{}'", key, ownerId, handler.javaClass.name) | |
| 64 | + } | |
| 65 | + | |
| 66 | + fun unregister(key: String): Boolean { | |
| 67 | + val removed = handlers.remove(key) ?: return false | |
| 68 | + log.info("unregistered JobHandler '{}' owner='{}'", key, removed.ownerId) | |
| 69 | + return true | |
| 70 | + } | |
| 71 | + | |
| 72 | + fun unregisterAllByOwner(ownerId: String): Int { | |
| 73 | + var removed = 0 | |
| 74 | + val iterator = handlers.entries.iterator() | |
| 75 | + while (iterator.hasNext()) { | |
| 76 | + val (key, entry) = iterator.next() | |
| 77 | + if (entry.ownerId == ownerId) { | |
| 78 | + iterator.remove() | |
| 79 | + removed += 1 | |
| 80 | + log.info("unregistered JobHandler '{}' (owner stopped)", key) | |
| 81 | + } | |
| 82 | + } | |
| 83 | + if (removed > 0) { | |
| 84 | + log.info("JobHandlerRegistry.unregisterAllByOwner('{}') removed {} handler(s)", ownerId, removed) | |
| 85 | + } | |
| 86 | + return removed | |
| 87 | + } | |
| 88 | + | |
| 89 | + fun find(key: String): JobHandler? = handlers[key]?.handler | |
| 90 | + | |
| 91 | + fun keys(): Set<String> = handlers.keys.toSet() | |
| 92 | + | |
| 93 | + fun size(): Int = handlers.size | |
| 94 | + | |
| 95 | + companion object { | |
| 96 | + const val OWNER_CORE: String = "core" | |
| 97 | + } | |
| 98 | + | |
| 99 | + private data class Entry(val handler: JobHandler, val ownerId: String) | |
| 100 | +} | ... | ... |
platform/platform-jobs/src/main/kotlin/org/vibeerp/platform/jobs/QuartzJobBridge.kt
0 → 100644
| 1 | +package org.vibeerp.platform.jobs | |
| 2 | + | |
| 3 | +import org.quartz.DisallowConcurrentExecution | |
| 4 | +import org.quartz.JobDataMap | |
| 5 | +import org.quartz.JobExecutionContext | |
| 6 | +import org.quartz.JobExecutionException | |
| 7 | +import org.quartz.PersistJobDataAfterExecution | |
| 8 | +import org.slf4j.LoggerFactory | |
| 9 | +import org.springframework.beans.factory.annotation.Autowired | |
| 10 | +import org.vibeerp.api.v1.core.Id | |
| 11 | +import org.vibeerp.api.v1.security.Principal | |
| 12 | +import org.vibeerp.platform.persistence.security.PrincipalContext | |
| 13 | +import java.util.Locale | |
| 14 | +import java.util.UUID | |
| 15 | + | |
| 16 | +/** | |
| 17 | + * The single Quartz-facing bridge: ONE `org.quartz.Job` implementation | |
| 18 | + * that every persistent trigger in the scheduler invokes. Analogous to | |
| 19 | + * `org.vibeerp.platform.workflow.DispatchingJavaDelegate` for the | |
| 20 | + * workflow engine: one shared bean, routing by key. | |
| 21 | + * | |
| 22 | + * **Routing.** The handler key lives in the Quartz `JobDataMap` under | |
| 23 | + * [KEY_HANDLER_KEY]. On every invocation the bridge reads the key, | |
| 24 | + * looks it up in the [JobHandlerRegistry], and executes the matching | |
| 25 | + * handler inside a `PrincipalContext.runAs("system:jobs:<key>")` | |
| 26 | + * block so the audit columns written by anything the handler | |
| 27 | + * touches carry a structured, greppable value ("system:jobs:core.audit.prune"). | |
| 28 | + * | |
| 29 | + * **Why the handler runs inside `runAs`.** PrincipalContext is a | |
| 30 | + * ThreadLocal populated by `PrincipalContextFilter` for HTTP | |
| 31 | + * requests; Quartz's worker threads have no such filter. Without the | |
| 32 | + * explicit runAs wrapper, any JPA write the handler performs would | |
| 33 | + * record `__system__` (the fallback) which is fine but loses the | |
| 34 | + * "which job wrote this row?" signal. Using `jobs:<key>` threads | |
| 35 | + * the source through every audit row. | |
| 36 | + * | |
| 37 | + * **@DisallowConcurrentExecution** guarantees that a long-running | |
| 38 | + * job cannot be started again while the previous invocation is | |
| 39 | + * still in flight. Most scheduled jobs should not overlap with | |
| 40 | + * themselves. Handlers that DO want concurrent execution can | |
| 41 | + * document it later with a different bridge subclass. | |
| 42 | + * | |
| 43 | + * **@PersistJobDataAfterExecution** keeps any updates to the | |
| 44 | + * JobDataMap across executions — the framework doesn't currently | |
| 45 | + * support mutating data in the handler (JobContext is read-only by | |
| 46 | + * design), but the annotation is cheap and leaves the door open. | |
| 47 | + * | |
| 48 | + * **Why @Autowired field injection** (not constructor injection): | |
| 49 | + * Quartz instantiates `Job` classes through its own | |
| 50 | + * `JobFactory.newJob(...)`. Spring Boot's Quartz auto-configuration | |
| 51 | + * wires up a `SpringBeanJobFactory` that autowires bean fields | |
| 52 | + * after construction, but does NOT call a constructor with Spring | |
| 53 | + * beans. Field injection is the documented pattern for | |
| 54 | + * Quartz-instantiated jobs in a Spring Boot app. | |
| 55 | + */ | |
| 56 | +@PersistJobDataAfterExecution | |
| 57 | +@DisallowConcurrentExecution | |
| 58 | +class QuartzJobBridge : org.quartz.Job { | |
| 59 | + | |
| 60 | + @Autowired | |
| 61 | + private lateinit var registry: JobHandlerRegistry | |
| 62 | + | |
| 63 | + private val log = LoggerFactory.getLogger(QuartzJobBridge::class.java) | |
| 64 | + | |
| 65 | + override fun execute(execution: JobExecutionContext) { | |
| 66 | + val data: JobDataMap = execution.mergedJobDataMap | |
| 67 | + val handlerKey = data.getString(KEY_HANDLER_KEY) | |
| 68 | + ?: throw JobExecutionException( | |
| 69 | + "QuartzJobBridge fired without '$KEY_HANDLER_KEY' in the JobDataMap " + | |
| 70 | + "(triggerKey=${execution.trigger.key})", | |
| 71 | + ) | |
| 72 | + | |
| 73 | + val handler = registry.find(handlerKey) | |
| 74 | + ?: throw JobExecutionException( | |
| 75 | + "no JobHandler registered for key '$handlerKey' " + | |
| 76 | + "(triggerKey=${execution.trigger.key}). Known keys: ${registry.keys().sorted()}", | |
| 77 | + ) | |
| 78 | + | |
| 79 | + val ctx = SimpleJobContext( | |
| 80 | + principal = Principal.System( | |
| 81 | + id = Id(SCHEDULED_JOB_PRINCIPAL_ID), | |
| 82 | + name = "jobs:$handlerKey", | |
| 83 | + ), | |
| 84 | + locale = Locale.ROOT, | |
| 85 | + data = jobDataMapToMap(data), | |
| 86 | + ) | |
| 87 | + | |
| 88 | + log.info( | |
| 89 | + "QuartzJobBridge firing handler='{}' trigger='{}' corr='{}'", | |
| 90 | + handlerKey, execution.trigger.key, ctx.correlationId(), | |
| 91 | + ) | |
| 92 | + | |
| 93 | + PrincipalContext.runAs("system:jobs:$handlerKey") { | |
| 94 | + try { | |
| 95 | + handler.execute(ctx) | |
| 96 | + } catch (ex: Throwable) { | |
| 97 | + log.error( | |
| 98 | + "JobHandler '{}' threw during execute — Quartz will mark this trigger as MISFIRED", | |
| 99 | + handlerKey, ex, | |
| 100 | + ) | |
| 101 | + // Re-wrap as JobExecutionException so Quartz's | |
| 102 | + // misfire/retry machinery handles it properly. | |
| 103 | + throw JobExecutionException(ex, /* refireImmediately = */ false) | |
| 104 | + } | |
| 105 | + } | |
| 106 | + } | |
| 107 | + | |
| 108 | + private fun jobDataMapToMap(data: JobDataMap): Map<String, Any?> { | |
| 109 | + // Strip the internal routing key — handler code must not | |
| 110 | + // depend on it being in JobContext.data. | |
| 111 | + return data.wrappedMap | |
| 112 | + .filterKeys { it != KEY_HANDLER_KEY } | |
| 113 | + .mapKeys { it.key.toString() } | |
| 114 | + } | |
| 115 | + | |
| 116 | + companion object { | |
| 117 | + /** | |
| 118 | + * JobDataMap key the bridge reads to find the target handler | |
| 119 | + * key. Constant so the scheduler implementation that puts | |
| 120 | + * the value in uses the same name as the bridge that reads | |
| 121 | + * it out. | |
| 122 | + */ | |
| 123 | + const val KEY_HANDLER_KEY: String = "__vibeerp_handler_key" | |
| 124 | + | |
| 125 | + /** | |
| 126 | + * Stable UUID identifying "the scheduler" as a principal. | |
| 127 | + * Appears in audit rows written by scheduled jobs as | |
| 128 | + * `created_by='system:jobs:<key>'`. | |
| 129 | + */ | |
| 130 | + private val SCHEDULED_JOB_PRINCIPAL_ID: UUID = | |
| 131 | + UUID.fromString("00000000-0000-0000-0000-0000000f10b5") | |
| 132 | + } | |
| 133 | +} | ... | ... |
platform/platform-jobs/src/main/kotlin/org/vibeerp/platform/jobs/QuartzJobScheduler.kt
0 → 100644
| 1 | +package org.vibeerp.platform.jobs | |
| 2 | + | |
| 3 | +import org.quartz.CronExpression | |
| 4 | +import org.quartz.CronScheduleBuilder | |
| 5 | +import org.quartz.JobBuilder | |
| 6 | +import org.quartz.JobDataMap | |
| 7 | +import org.quartz.JobKey | |
| 8 | +import org.quartz.Scheduler | |
| 9 | +import org.quartz.SimpleScheduleBuilder | |
| 10 | +import org.quartz.Trigger | |
| 11 | +import org.quartz.TriggerBuilder | |
| 12 | +import org.quartz.TriggerKey | |
| 13 | +import org.slf4j.LoggerFactory | |
| 14 | +import org.springframework.stereotype.Service | |
| 15 | +import org.vibeerp.api.v1.core.Id | |
| 16 | +import org.vibeerp.api.v1.jobs.JobExecutionSummary | |
| 17 | +import org.vibeerp.api.v1.jobs.JobScheduler | |
| 18 | +import org.vibeerp.api.v1.jobs.ScheduleKind | |
| 19 | +import org.vibeerp.api.v1.jobs.ScheduledJobInfo | |
| 20 | +import org.vibeerp.api.v1.security.Principal | |
| 21 | +import org.vibeerp.platform.security.authz.AuthorizationContext | |
| 22 | +import java.time.Instant | |
| 23 | +import java.util.Date | |
| 24 | +import java.util.Locale | |
| 25 | +import java.util.UUID | |
| 26 | + | |
| 27 | +/** | |
| 28 | + * The concrete api.v1 [JobScheduler] implementation. Everything here | |
| 29 | + * is vibe_erp-internal; plug-ins never import this class — they | |
| 30 | + * inject the api.v1 interface and the Spring context hands them this | |
| 31 | + * bean. | |
| 32 | + * | |
| 33 | + * **Scheduling model.** Quartz's "job + trigger" split is | |
| 34 | + * deliberately hidden from the api.v1 contract: the framework | |
| 35 | + * exposes only "schedules" which the caller identifies by a single | |
| 36 | + * [scheduleKey]. Internally each schedule maps to one Quartz | |
| 37 | + * `JobDetail` + one `Trigger`, both keyed by `scheduleKey` under a | |
| 38 | + * fixed group name [JOB_GROUP]. The detail's job class is always | |
| 39 | + * [QuartzJobBridge]; the real handler key lives in the JobDataMap | |
| 40 | + * under [QuartzJobBridge.KEY_HANDLER_KEY]. | |
| 41 | + * | |
| 42 | + * **Why every schedule has its own JobDetail** (rather than sharing | |
| 43 | + * one JobDetail per handler and pointing many triggers at it): | |
| 44 | + * the JobDataMap is attached to the JobDetail, and each schedule | |
| 45 | + * needs its own data payload. Shared job details would mean all | |
| 46 | + * triggers for the same handler saw the same data, which breaks | |
| 47 | + * the "same handler, two different cron schedules, different | |
| 48 | + * arguments" use case. | |
| 49 | + * | |
| 50 | + * **Persistence.** The Spring Boot Quartz starter is configured | |
| 51 | + * with `spring.quartz.job-store-type=jdbc` against the host | |
| 52 | + * Postgres, so schedules survive restarts. The first boot creates | |
| 53 | + * the QRTZ_* tables via `spring.quartz.jdbc.initialize-schema=always` | |
| 54 | + * (idempotent — Spring Boot's `QuartzDataSourceScriptDatabaseInitializer` | |
| 55 | + * skips if the tables already exist). | |
| 56 | + */ | |
| 57 | +@Service | |
| 58 | +class QuartzJobScheduler( | |
| 59 | + private val scheduler: Scheduler, | |
| 60 | + private val registry: JobHandlerRegistry, | |
| 61 | +) : JobScheduler { | |
| 62 | + | |
| 63 | + private val log = LoggerFactory.getLogger(QuartzJobScheduler::class.java) | |
| 64 | + | |
| 65 | + override fun scheduleCron( | |
| 66 | + scheduleKey: String, | |
| 67 | + handlerKey: String, | |
| 68 | + cronExpression: String, | |
| 69 | + data: Map<String, Any?>, | |
| 70 | + ) { | |
| 71 | + require(scheduleKey.isNotBlank()) { "scheduleKey must not be blank" } | |
| 72 | + require(handlerKey.isNotBlank()) { "handlerKey must not be blank" } | |
| 73 | + require(cronExpression.isNotBlank()) { "cronExpression must not be blank" } | |
| 74 | + requireHandlerRegistered(handlerKey) | |
| 75 | + require(CronExpression.isValidExpression(cronExpression)) { | |
| 76 | + "invalid Quartz cron expression: '$cronExpression'" | |
| 77 | + } | |
| 78 | + | |
| 79 | + val jobKey = jobKey(scheduleKey) | |
| 80 | + val triggerKey = triggerKey(scheduleKey) | |
| 81 | + | |
| 82 | + val jobDetail = JobBuilder.newJob(QuartzJobBridge::class.java) | |
| 83 | + .withIdentity(jobKey) | |
| 84 | + .usingJobData(buildJobData(handlerKey, data)) | |
| 85 | + .storeDurably(true) | |
| 86 | + .build() | |
| 87 | + | |
| 88 | + val trigger: Trigger = TriggerBuilder.newTrigger() | |
| 89 | + .withIdentity(triggerKey) | |
| 90 | + .forJob(jobKey) | |
| 91 | + .withSchedule( | |
| 92 | + CronScheduleBuilder.cronSchedule(cronExpression) | |
| 93 | + .withMisfireHandlingInstructionDoNothing(), | |
| 94 | + ) | |
| 95 | + .build() | |
| 96 | + | |
| 97 | + // addJob(..., replace = true) is idempotent on the JobKey; | |
| 98 | + // scheduleJob(...) is NOT idempotent on the TriggerKey, so | |
| 99 | + // we explicitly unschedule first. | |
| 100 | + scheduler.addJob(jobDetail, /* replace = */ true, /* storeNonDurableWhileAwaitingScheduling = */ true) | |
| 101 | + if (scheduler.checkExists(triggerKey)) { | |
| 102 | + scheduler.rescheduleJob(triggerKey, trigger) | |
| 103 | + } else { | |
| 104 | + scheduler.scheduleJob(trigger) | |
| 105 | + } | |
| 106 | + log.info("scheduled CRON '{}' -> handler '{}' cron='{}'", scheduleKey, handlerKey, cronExpression) | |
| 107 | + } | |
| 108 | + | |
| 109 | + override fun scheduleOnce( | |
| 110 | + scheduleKey: String, | |
| 111 | + handlerKey: String, | |
| 112 | + runAt: Instant, | |
| 113 | + data: Map<String, Any?>, | |
| 114 | + ) { | |
| 115 | + require(scheduleKey.isNotBlank()) { "scheduleKey must not be blank" } | |
| 116 | + require(handlerKey.isNotBlank()) { "handlerKey must not be blank" } | |
| 117 | + requireHandlerRegistered(handlerKey) | |
| 118 | + | |
| 119 | + val jobKey = jobKey(scheduleKey) | |
| 120 | + val triggerKey = triggerKey(scheduleKey) | |
| 121 | + | |
| 122 | + val jobDetail = JobBuilder.newJob(QuartzJobBridge::class.java) | |
| 123 | + .withIdentity(jobKey) | |
| 124 | + .usingJobData(buildJobData(handlerKey, data)) | |
| 125 | + .storeDurably(true) | |
| 126 | + .build() | |
| 127 | + | |
| 128 | + val trigger: Trigger = TriggerBuilder.newTrigger() | |
| 129 | + .withIdentity(triggerKey) | |
| 130 | + .forJob(jobKey) | |
| 131 | + .startAt(Date.from(runAt)) | |
| 132 | + .withSchedule( | |
| 133 | + SimpleScheduleBuilder.simpleSchedule() | |
| 134 | + .withMisfireHandlingInstructionFireNow(), | |
| 135 | + ) | |
| 136 | + .build() | |
| 137 | + | |
| 138 | + scheduler.addJob(jobDetail, true, true) | |
| 139 | + if (scheduler.checkExists(triggerKey)) { | |
| 140 | + scheduler.rescheduleJob(triggerKey, trigger) | |
| 141 | + } else { | |
| 142 | + scheduler.scheduleJob(trigger) | |
| 143 | + } | |
| 144 | + log.info("scheduled ONCE '{}' -> handler '{}' runAt={}", scheduleKey, handlerKey, runAt) | |
| 145 | + } | |
| 146 | + | |
| 147 | + override fun unschedule(scheduleKey: String): Boolean { | |
| 148 | + val jobKey = jobKey(scheduleKey) | |
| 149 | + val existed = scheduler.checkExists(jobKey) | |
| 150 | + if (existed) { | |
| 151 | + scheduler.deleteJob(jobKey) | |
| 152 | + log.info("unscheduled '{}'", scheduleKey) | |
| 153 | + } | |
| 154 | + return existed | |
| 155 | + } | |
| 156 | + | |
| 157 | + override fun triggerNow( | |
| 158 | + handlerKey: String, | |
| 159 | + data: Map<String, Any?>, | |
| 160 | + ): JobExecutionSummary { | |
| 161 | + require(handlerKey.isNotBlank()) { "handlerKey must not be blank" } | |
| 162 | + val handler = registry.find(handlerKey) | |
| 163 | + ?: throw IllegalArgumentException("no JobHandler registered for key '$handlerKey'") | |
| 164 | + | |
| 165 | + val principal = callerPrincipal() | |
| 166 | + val ctx = SimpleJobContext( | |
| 167 | + principal = principal, | |
| 168 | + locale = Locale.ROOT, | |
| 169 | + data = data, | |
| 170 | + ) | |
| 171 | + val startedAt = ctx.startedAt() | |
| 172 | + val correlationId = ctx.correlationId() | |
| 173 | + | |
| 174 | + log.info( | |
| 175 | + "triggerNow handler='{}' corr='{}' principal='{}'", | |
| 176 | + handlerKey, correlationId, principal.javaClass.simpleName, | |
| 177 | + ) | |
| 178 | + | |
| 179 | + var ok = false | |
| 180 | + try { | |
| 181 | + handler.execute(ctx) | |
| 182 | + ok = true | |
| 183 | + } finally { | |
| 184 | + log.info( | |
| 185 | + "triggerNow handler='{}' corr='{}' ok={}", | |
| 186 | + handlerKey, correlationId, ok, | |
| 187 | + ) | |
| 188 | + } | |
| 189 | + return JobExecutionSummary( | |
| 190 | + handlerKey = handlerKey, | |
| 191 | + correlationId = correlationId, | |
| 192 | + startedAt = startedAt, | |
| 193 | + finishedAt = Instant.now(), | |
| 194 | + ok = ok, | |
| 195 | + ) | |
| 196 | + } | |
| 197 | + | |
| 198 | + override fun listScheduled(): List<ScheduledJobInfo> { | |
| 199 | + val keys = scheduler.getTriggerKeys( | |
| 200 | + org.quartz.impl.matchers.GroupMatcher.triggerGroupEquals(TRIGGER_GROUP), | |
| 201 | + ) | |
| 202 | + return keys.mapNotNull { tKey -> | |
| 203 | + val trigger = scheduler.getTrigger(tKey) ?: return@mapNotNull null | |
| 204 | + val jobDetail = scheduler.getJobDetail(trigger.jobKey) ?: return@mapNotNull null | |
| 205 | + val handlerKey = jobDetail.jobDataMap.getString(QuartzJobBridge.KEY_HANDLER_KEY) ?: "<unknown>" | |
| 206 | + val (kind, cron) = when (trigger) { | |
| 207 | + is org.quartz.CronTrigger -> ScheduleKind.CRON to trigger.cronExpression | |
| 208 | + else -> ScheduleKind.ONCE to null | |
| 209 | + } | |
| 210 | + ScheduledJobInfo( | |
| 211 | + scheduleKey = tKey.name, | |
| 212 | + handlerKey = handlerKey, | |
| 213 | + kind = kind, | |
| 214 | + cronExpression = cron, | |
| 215 | + nextFireTime = trigger.nextFireTime?.toInstant(), | |
| 216 | + previousFireTime = trigger.previousFireTime?.toInstant(), | |
| 217 | + data = jobDetail.jobDataMap.wrappedMap | |
| 218 | + .filterKeys { it != QuartzJobBridge.KEY_HANDLER_KEY } | |
| 219 | + .mapKeys { it.key.toString() }, | |
| 220 | + ) | |
| 221 | + } | |
| 222 | + } | |
| 223 | + | |
| 224 | + // ─── internals ───────────────────────────────────────────────── | |
| 225 | + | |
| 226 | + private fun requireHandlerRegistered(handlerKey: String) { | |
| 227 | + require(registry.find(handlerKey) != null) { | |
| 228 | + "no JobHandler registered for key '$handlerKey' " + | |
| 229 | + "(known keys: ${registry.keys().sorted()})" | |
| 230 | + } | |
| 231 | + } | |
| 232 | + | |
| 233 | + private fun jobKey(scheduleKey: String): JobKey = JobKey.jobKey(scheduleKey, JOB_GROUP) | |
| 234 | + private fun triggerKey(scheduleKey: String): TriggerKey = TriggerKey.triggerKey(scheduleKey, TRIGGER_GROUP) | |
| 235 | + | |
| 236 | + private fun buildJobData(handlerKey: String, data: Map<String, Any?>): JobDataMap { | |
| 237 | + val map = JobDataMap() | |
| 238 | + map[QuartzJobBridge.KEY_HANDLER_KEY] = handlerKey | |
| 239 | + for ((k, v) in data) { | |
| 240 | + if (k == QuartzJobBridge.KEY_HANDLER_KEY) continue | |
| 241 | + // Quartz's JobDataMap only persists Java-serializable | |
| 242 | + // values when using the JDBC store. Stringify anything | |
| 243 | + // that isn't a String/Number/Boolean/null to stay safe. | |
| 244 | + map[k] = when (v) { | |
| 245 | + null -> null | |
| 246 | + is String, is Number, is Boolean -> v | |
| 247 | + else -> v.toString() | |
| 248 | + } | |
| 249 | + } | |
| 250 | + return map | |
| 251 | + } | |
| 252 | + | |
| 253 | + private fun callerPrincipal(): Principal { | |
| 254 | + val current = AuthorizationContext.current() | |
| 255 | + return if (current != null) { | |
| 256 | + Principal.User( | |
| 257 | + id = Id( | |
| 258 | + runCatching { UUID.fromString(current.id) } | |
| 259 | + .getOrElse { UUID.nameUUIDFromBytes(current.id.toByteArray()) }, | |
| 260 | + ), | |
| 261 | + username = current.username, | |
| 262 | + ) | |
| 263 | + } else { | |
| 264 | + Principal.System( | |
| 265 | + id = Id(UUID.fromString("00000000-0000-0000-0000-0000000f10b5")), | |
| 266 | + name = "jobs:manual-trigger", | |
| 267 | + ) | |
| 268 | + } | |
| 269 | + } | |
| 270 | + | |
| 271 | + companion object { | |
| 272 | + const val JOB_GROUP: String = "vibeerp-jobs" | |
| 273 | + const val TRIGGER_GROUP: String = "vibeerp-jobs" | |
| 274 | + } | |
| 275 | +} | ... | ... |
platform/platform-jobs/src/main/kotlin/org/vibeerp/platform/jobs/SimpleJobContext.kt
0 → 100644
| 1 | +package org.vibeerp.platform.jobs | |
| 2 | + | |
| 3 | +import org.vibeerp.api.v1.jobs.JobContext | |
| 4 | +import org.vibeerp.api.v1.security.Principal | |
| 5 | +import java.time.Instant | |
| 6 | +import java.util.Locale | |
| 7 | +import java.util.UUID | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * Internal immutable implementation of api.v1 [JobContext]. Plug-ins | |
| 11 | + * never see this class — they only see the interface — and the | |
| 12 | + * Quartz bridge constructs a fresh instance for every job execution. | |
| 13 | + * | |
| 14 | + * Defensive copy of [data] happens at construction time so a handler | |
| 15 | + * can't mutate the caller's original map (and vice versa). | |
| 16 | + */ | |
| 17 | +internal class SimpleJobContext( | |
| 18 | + private val principal: Principal, | |
| 19 | + private val locale: Locale, | |
| 20 | + private val correlationId: String = UUID.randomUUID().toString(), | |
| 21 | + private val startedAt: Instant = Instant.now(), | |
| 22 | + data: Map<String, Any?> = emptyMap(), | |
| 23 | +) : JobContext { | |
| 24 | + | |
| 25 | + private val snapshot: Map<String, Any?> = data.toMap() | |
| 26 | + | |
| 27 | + override fun principal(): Principal = principal | |
| 28 | + override fun locale(): Locale = locale | |
| 29 | + override fun correlationId(): String = correlationId | |
| 30 | + override fun startedAt(): Instant = startedAt | |
| 31 | + override fun data(): Map<String, Any?> = snapshot | |
| 32 | +} | ... | ... |
platform/platform-jobs/src/main/kotlin/org/vibeerp/platform/jobs/builtin/VibeErpPingJobHandler.kt
0 → 100644
| 1 | +package org.vibeerp.platform.jobs.builtin | |
| 2 | + | |
| 3 | +import org.slf4j.LoggerFactory | |
| 4 | +import org.springframework.stereotype.Component | |
| 5 | +import org.vibeerp.api.v1.jobs.JobContext | |
| 6 | +import org.vibeerp.api.v1.jobs.JobHandler | |
| 7 | +import org.vibeerp.api.v1.security.Principal | |
| 8 | +import java.time.Instant | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * Built-in diagnostic job handler. Mirrors `PingTaskHandler` from | |
| 12 | + * platform-workflow: a trivial self-test the operator can fire via | |
| 13 | + * `POST /api/v1/jobs/handlers/vibeerp.jobs.ping/trigger` to prove | |
| 14 | + * that the Quartz engine, the JobHandlerRegistry, and the | |
| 15 | + * QuartzJobBridge are all wired end-to-end without deploying a | |
| 16 | + * real cron job. | |
| 17 | + * | |
| 18 | + * The handler logs the invocation (principal label + correlation id | |
| 19 | + * + data payload) and exits. No side effects. Safe to trigger from | |
| 20 | + * any environment. | |
| 21 | + */ | |
| 22 | +@Component | |
| 23 | +class VibeErpPingJobHandler : JobHandler { | |
| 24 | + | |
| 25 | + private val log = LoggerFactory.getLogger(VibeErpPingJobHandler::class.java) | |
| 26 | + | |
| 27 | + override fun key(): String = KEY | |
| 28 | + | |
| 29 | + override fun execute(context: JobContext) { | |
| 30 | + val principalLabel = when (val p = context.principal()) { | |
| 31 | + is Principal.User -> "user:${p.username}" | |
| 32 | + is Principal.System -> "system:${p.name}" | |
| 33 | + is Principal.PluginPrincipal -> "plugin:${p.pluginId}" | |
| 34 | + } | |
| 35 | + log.info( | |
| 36 | + "VibeErpPingJobHandler invoked at={} principal='{}' corr='{}' data={}", | |
| 37 | + Instant.now(), | |
| 38 | + principalLabel, | |
| 39 | + context.correlationId(), | |
| 40 | + context.data(), | |
| 41 | + ) | |
| 42 | + } | |
| 43 | + | |
| 44 | + companion object { | |
| 45 | + const val KEY: String = "vibeerp.jobs.ping" | |
| 46 | + } | |
| 47 | +} | ... | ... |
platform/platform-jobs/src/main/kotlin/org/vibeerp/platform/jobs/http/JobController.kt
0 → 100644
| 1 | +package org.vibeerp.platform.jobs.http | |
| 2 | + | |
| 3 | +import org.springframework.http.HttpStatus | |
| 4 | +import org.springframework.http.ResponseEntity | |
| 5 | +import org.springframework.web.bind.annotation.DeleteMapping | |
| 6 | +import org.springframework.web.bind.annotation.ExceptionHandler | |
| 7 | +import org.springframework.web.bind.annotation.GetMapping | |
| 8 | +import org.springframework.web.bind.annotation.PathVariable | |
| 9 | +import org.springframework.web.bind.annotation.PostMapping | |
| 10 | +import org.springframework.web.bind.annotation.RequestBody | |
| 11 | +import org.springframework.web.bind.annotation.RequestMapping | |
| 12 | +import org.springframework.web.bind.annotation.RestController | |
| 13 | +import org.vibeerp.api.v1.jobs.JobExecutionSummary | |
| 14 | +import org.vibeerp.api.v1.jobs.JobScheduler | |
| 15 | +import org.vibeerp.api.v1.jobs.ScheduledJobInfo | |
| 16 | +import org.vibeerp.platform.jobs.JobHandlerRegistry | |
| 17 | +import org.vibeerp.platform.security.authz.RequirePermission | |
| 18 | +import java.time.Instant | |
| 19 | + | |
| 20 | +/** | |
| 21 | + * Minimal HTTP surface for the framework's job scheduler. | |
| 22 | + * | |
| 23 | + * - `GET /api/v1/jobs/handlers` list registered JobHandler keys | |
| 24 | + * - `POST /api/v1/jobs/handlers/{key}/trigger` fire a handler NOW (manual) | |
| 25 | + * - `GET /api/v1/jobs/scheduled` list currently scheduled jobs | |
| 26 | + * - `POST /api/v1/jobs/scheduled` schedule a cron or one-shot job | |
| 27 | + * - `DELETE /api/v1/jobs/scheduled/{key}` unschedule a job | |
| 28 | + * | |
| 29 | + * Permission-gated the same way every other controller is, via | |
| 30 | + * [org.vibeerp.platform.security.authz.RequirePermission]. | |
| 31 | + */ | |
| 32 | +@RestController | |
| 33 | +@RequestMapping("/api/v1/jobs") | |
| 34 | +class JobController( | |
| 35 | + private val jobScheduler: JobScheduler, | |
| 36 | + private val handlerRegistry: JobHandlerRegistry, | |
| 37 | +) { | |
| 38 | + | |
| 39 | + @GetMapping("/handlers") | |
| 40 | + @RequirePermission("jobs.handler.read") | |
| 41 | + fun listHandlers(): HandlersResponse = HandlersResponse( | |
| 42 | + count = handlerRegistry.size(), | |
| 43 | + keys = handlerRegistry.keys().sorted(), | |
| 44 | + ) | |
| 45 | + | |
| 46 | + @PostMapping("/handlers/{key}/trigger") | |
| 47 | + @RequirePermission("jobs.job.trigger") | |
| 48 | + fun trigger( | |
| 49 | + @PathVariable key: String, | |
| 50 | + @RequestBody(required = false) request: TriggerRequest?, | |
| 51 | + ): ResponseEntity<JobExecutionSummary> { | |
| 52 | + val summary = jobScheduler.triggerNow(key, request?.data ?: emptyMap()) | |
| 53 | + return ResponseEntity.status(HttpStatus.OK).body(summary) | |
| 54 | + } | |
| 55 | + | |
| 56 | + @GetMapping("/scheduled") | |
| 57 | + @RequirePermission("jobs.schedule.read") | |
| 58 | + fun listScheduled(): List<ScheduledJobInfo> = jobScheduler.listScheduled() | |
| 59 | + | |
| 60 | + @PostMapping("/scheduled") | |
| 61 | + @RequirePermission("jobs.schedule.write") | |
| 62 | + fun schedule(@RequestBody request: ScheduleRequest): ResponseEntity<ScheduledResponse> { | |
| 63 | + when { | |
| 64 | + request.cronExpression != null -> jobScheduler.scheduleCron( | |
| 65 | + scheduleKey = request.scheduleKey, | |
| 66 | + handlerKey = request.handlerKey, | |
| 67 | + cronExpression = request.cronExpression, | |
| 68 | + data = request.data ?: emptyMap(), | |
| 69 | + ) | |
| 70 | + request.runAt != null -> jobScheduler.scheduleOnce( | |
| 71 | + scheduleKey = request.scheduleKey, | |
| 72 | + handlerKey = request.handlerKey, | |
| 73 | + runAt = request.runAt, | |
| 74 | + data = request.data ?: emptyMap(), | |
| 75 | + ) | |
| 76 | + else -> throw IllegalArgumentException( | |
| 77 | + "schedule request must include either 'cronExpression' or 'runAt'", | |
| 78 | + ) | |
| 79 | + } | |
| 80 | + return ResponseEntity.status(HttpStatus.CREATED).body( | |
| 81 | + ScheduledResponse(scheduleKey = request.scheduleKey, handlerKey = request.handlerKey), | |
| 82 | + ) | |
| 83 | + } | |
| 84 | + | |
| 85 | + @DeleteMapping("/scheduled/{key}") | |
| 86 | + @RequirePermission("jobs.schedule.write") | |
| 87 | + fun unschedule(@PathVariable key: String): ResponseEntity<UnscheduleResponse> { | |
| 88 | + val removed = jobScheduler.unschedule(key) | |
| 89 | + return if (removed) { | |
| 90 | + ResponseEntity.ok(UnscheduleResponse(scheduleKey = key, removed = true)) | |
| 91 | + } else { | |
| 92 | + ResponseEntity.status(HttpStatus.NOT_FOUND) | |
| 93 | + .body(UnscheduleResponse(scheduleKey = key, removed = false)) | |
| 94 | + } | |
| 95 | + } | |
| 96 | + | |
| 97 | + @ExceptionHandler(IllegalArgumentException::class) | |
| 98 | + fun handleBadRequest(ex: IllegalArgumentException): ResponseEntity<ErrorResponse> = | |
| 99 | + ResponseEntity.status(HttpStatus.BAD_REQUEST) | |
| 100 | + .body(ErrorResponse(message = ex.message ?: "bad request")) | |
| 101 | +} | |
| 102 | + | |
| 103 | +// ─── DTOs ──────────────────────────────────────────────────────────── | |
| 104 | + | |
| 105 | +data class TriggerRequest( | |
| 106 | + val data: Map<String, Any?>? = null, | |
| 107 | +) | |
| 108 | + | |
| 109 | +data class ScheduleRequest( | |
| 110 | + val scheduleKey: String, | |
| 111 | + val handlerKey: String, | |
| 112 | + /** Quartz cron expression. Mutually exclusive with [runAt]. */ | |
| 113 | + val cronExpression: String? = null, | |
| 114 | + /** One-shot fire time. Mutually exclusive with [cronExpression]. */ | |
| 115 | + val runAt: Instant? = null, | |
| 116 | + val data: Map<String, Any?>? = null, | |
| 117 | +) | |
| 118 | + | |
| 119 | +data class HandlersResponse( | |
| 120 | + val count: Int, | |
| 121 | + val keys: List<String>, | |
| 122 | +) | |
| 123 | + | |
| 124 | +data class ScheduledResponse( | |
| 125 | + val scheduleKey: String, | |
| 126 | + val handlerKey: String, | |
| 127 | +) | |
| 128 | + | |
| 129 | +data class UnscheduleResponse( | |
| 130 | + val scheduleKey: String, | |
| 131 | + val removed: Boolean, | |
| 132 | +) | |
| 133 | + | |
| 134 | +data class ErrorResponse( | |
| 135 | + val message: String, | |
| 136 | +) | ... | ... |
platform/platform-jobs/src/main/resources/META-INF/vibe-erp/metadata/jobs.yml
0 → 100644
| 1 | +# platform-jobs metadata. | |
| 2 | +# | |
| 3 | +# Loaded at boot by MetadataLoader, tagged source='core'. | |
| 4 | + | |
| 5 | +permissions: | |
| 6 | + - key: jobs.handler.read | |
| 7 | + description: List registered JobHandler keys | |
| 8 | + - key: jobs.job.trigger | |
| 9 | + description: Manually trigger a registered JobHandler (runs synchronously) | |
| 10 | + - key: jobs.schedule.read | |
| 11 | + description: List currently scheduled jobs | |
| 12 | + - key: jobs.schedule.write | |
| 13 | + description: Schedule or unschedule jobs (cron + one-shot) | |
| 14 | + | |
| 15 | +menus: | |
| 16 | + - path: /jobs/handlers | |
| 17 | + label: Job handlers | |
| 18 | + icon: wrench | |
| 19 | + section: Jobs | |
| 20 | + order: 800 | |
| 21 | + - path: /jobs/scheduled | |
| 22 | + label: Scheduled jobs | |
| 23 | + icon: clock | |
| 24 | + section: Jobs | |
| 25 | + order: 810 | ... | ... |
platform/platform-jobs/src/test/kotlin/org/vibeerp/platform/jobs/JobHandlerRegistryTest.kt
0 → 100644
| 1 | +package org.vibeerp.platform.jobs | |
| 2 | + | |
| 3 | +import assertk.assertFailure | |
| 4 | +import assertk.assertThat | |
| 5 | +import assertk.assertions.hasMessage | |
| 6 | +import assertk.assertions.isEqualTo | |
| 7 | +import assertk.assertions.isInstanceOf | |
| 8 | +import assertk.assertions.isNull | |
| 9 | +import org.junit.jupiter.api.Test | |
| 10 | +import org.vibeerp.api.v1.jobs.JobContext | |
| 11 | +import org.vibeerp.api.v1.jobs.JobHandler | |
| 12 | + | |
| 13 | +class JobHandlerRegistryTest { | |
| 14 | + | |
| 15 | + private class FakeHandler(private val k: String) : JobHandler { | |
| 16 | + override fun key(): String = k | |
| 17 | + override fun execute(context: JobContext) { /* no-op */ } | |
| 18 | + } | |
| 19 | + | |
| 20 | + @Test | |
| 21 | + fun `initial handlers are registered with OWNER_CORE`() { | |
| 22 | + val registry = JobHandlerRegistry(listOf(FakeHandler("a.b.c"), FakeHandler("x.y.z"))) | |
| 23 | + assertThat(registry.size()).isEqualTo(2) | |
| 24 | + assertThat(registry.keys()).isEqualTo(setOf("a.b.c", "x.y.z")) | |
| 25 | + } | |
| 26 | + | |
| 27 | + @Test | |
| 28 | + fun `duplicate key fails fast with both owners in the error`() { | |
| 29 | + val registry = JobHandlerRegistry() | |
| 30 | + registry.register(FakeHandler("dup.key"), ownerId = "core") | |
| 31 | + | |
| 32 | + assertFailure { registry.register(FakeHandler("dup.key"), ownerId = "printing-shop") } | |
| 33 | + .isInstanceOf(IllegalStateException::class) | |
| 34 | + .hasMessage( | |
| 35 | + "duplicate JobHandler key 'dup.key' — already registered by " + | |
| 36 | + "org.vibeerp.platform.jobs.JobHandlerRegistryTest\$FakeHandler (owner='core'), " + | |
| 37 | + "attempted to register org.vibeerp.platform.jobs.JobHandlerRegistryTest\$FakeHandler (owner='printing-shop')", | |
| 38 | + ) | |
| 39 | + } | |
| 40 | + | |
| 41 | + @Test | |
| 42 | + fun `unregisterAllByOwner only removes handlers owned by that id`() { | |
| 43 | + val registry = JobHandlerRegistry() | |
| 44 | + registry.register(FakeHandler("core.a"), ownerId = "core") | |
| 45 | + registry.register(FakeHandler("core.b"), ownerId = "core") | |
| 46 | + registry.register(FakeHandler("plugin.a"), ownerId = "printing-shop") | |
| 47 | + registry.register(FakeHandler("plugin.b"), ownerId = "printing-shop") | |
| 48 | + | |
| 49 | + val removed = registry.unregisterAllByOwner("printing-shop") | |
| 50 | + assertThat(removed).isEqualTo(2) | |
| 51 | + assertThat(registry.size()).isEqualTo(2) | |
| 52 | + assertThat(registry.keys()).isEqualTo(setOf("core.a", "core.b")) | |
| 53 | + } | |
| 54 | + | |
| 55 | + @Test | |
| 56 | + fun `unregister by key returns false for unknown`() { | |
| 57 | + val registry = JobHandlerRegistry() | |
| 58 | + assertThat(registry.unregister("never.seen")).isEqualTo(false) | |
| 59 | + } | |
| 60 | + | |
| 61 | + @Test | |
| 62 | + fun `find on missing key returns null`() { | |
| 63 | + val registry = JobHandlerRegistry() | |
| 64 | + assertThat(registry.find("nope")).isNull() | |
| 65 | + } | |
| 66 | + | |
| 67 | + @Test | |
| 68 | + fun `blank key is rejected`() { | |
| 69 | + val registry = JobHandlerRegistry() | |
| 70 | + assertFailure { registry.register(object : JobHandler { | |
| 71 | + override fun key(): String = " " | |
| 72 | + override fun execute(context: JobContext) { } | |
| 73 | + }) }.isInstanceOf(IllegalArgumentException::class) | |
| 74 | + } | |
| 75 | +} | ... | ... |
platform/platform-jobs/src/test/kotlin/org/vibeerp/platform/jobs/QuartzJobSchedulerTest.kt
0 → 100644
| 1 | +package org.vibeerp.platform.jobs | |
| 2 | + | |
| 3 | +import assertk.assertFailure | |
| 4 | +import assertk.assertThat | |
| 5 | +import assertk.assertions.isEqualTo | |
| 6 | +import assertk.assertions.isFalse | |
| 7 | +import assertk.assertions.isInstanceOf | |
| 8 | +import assertk.assertions.isTrue | |
| 9 | +import io.mockk.Runs | |
| 10 | +import io.mockk.every | |
| 11 | +import io.mockk.just | |
| 12 | +import io.mockk.mockk | |
| 13 | +import io.mockk.slot | |
| 14 | +import io.mockk.verify | |
| 15 | +import org.junit.jupiter.api.Test | |
| 16 | +import org.quartz.JobBuilder | |
| 17 | +import org.quartz.JobDetail | |
| 18 | +import org.quartz.JobKey | |
| 19 | +import org.quartz.Scheduler | |
| 20 | +import org.quartz.Trigger | |
| 21 | +import org.quartz.TriggerKey | |
| 22 | +import org.vibeerp.api.v1.jobs.JobContext | |
| 23 | +import org.vibeerp.api.v1.jobs.JobHandler | |
| 24 | +import java.time.Instant | |
| 25 | + | |
| 26 | +class QuartzJobSchedulerTest { | |
| 27 | + | |
| 28 | + private class FakeHandler(private val k: String) : JobHandler { | |
| 29 | + override fun key(): String = k | |
| 30 | + var executed = 0 | |
| 31 | + private set | |
| 32 | + override fun execute(context: JobContext) { | |
| 33 | + executed += 1 | |
| 34 | + } | |
| 35 | + } | |
| 36 | + | |
| 37 | + private val scheduler: Scheduler = mockk(relaxed = true) | |
| 38 | + private val registry = JobHandlerRegistry(listOf(FakeHandler("core.test.ping"))) | |
| 39 | + private val subject = QuartzJobScheduler(scheduler, registry) | |
| 40 | + | |
| 41 | + @Test | |
| 42 | + fun `scheduleCron rejects an unknown handler key`() { | |
| 43 | + assertFailure { | |
| 44 | + subject.scheduleCron( | |
| 45 | + scheduleKey = "nightly", | |
| 46 | + handlerKey = "nope", | |
| 47 | + cronExpression = "0 0 2 * * ?", | |
| 48 | + ) | |
| 49 | + }.isInstanceOf(IllegalArgumentException::class) | |
| 50 | + verify(exactly = 0) { scheduler.scheduleJob(any<Trigger>()) } | |
| 51 | + } | |
| 52 | + | |
| 53 | + @Test | |
| 54 | + fun `scheduleCron rejects an invalid cron expression`() { | |
| 55 | + assertFailure { | |
| 56 | + subject.scheduleCron( | |
| 57 | + scheduleKey = "nightly", | |
| 58 | + handlerKey = "core.test.ping", | |
| 59 | + cronExpression = "not a cron", | |
| 60 | + ) | |
| 61 | + }.isInstanceOf(IllegalArgumentException::class) | |
| 62 | + } | |
| 63 | + | |
| 64 | + @Test | |
| 65 | + fun `scheduleCron adds job + schedules trigger when nothing exists yet`() { | |
| 66 | + every { scheduler.checkExists(any<TriggerKey>()) } returns false | |
| 67 | + | |
| 68 | + subject.scheduleCron( | |
| 69 | + scheduleKey = "nightly", | |
| 70 | + handlerKey = "core.test.ping", | |
| 71 | + cronExpression = "0 0 2 * * ?", | |
| 72 | + data = mapOf("retain_days" to 90), | |
| 73 | + ) | |
| 74 | + | |
| 75 | + verify(exactly = 1) { scheduler.addJob(any<JobDetail>(), true, true) } | |
| 76 | + verify(exactly = 1) { scheduler.scheduleJob(any<Trigger>()) } | |
| 77 | + verify(exactly = 0) { scheduler.rescheduleJob(any(), any()) } | |
| 78 | + } | |
| 79 | + | |
| 80 | + @Test | |
| 81 | + fun `scheduleCron reschedules when the trigger already exists`() { | |
| 82 | + every { scheduler.checkExists(any<TriggerKey>()) } returns true | |
| 83 | + every { scheduler.rescheduleJob(any<TriggerKey>(), any<Trigger>()) } returns null | |
| 84 | + | |
| 85 | + subject.scheduleCron( | |
| 86 | + scheduleKey = "nightly", | |
| 87 | + handlerKey = "core.test.ping", | |
| 88 | + cronExpression = "0 0 3 * * ?", | |
| 89 | + ) | |
| 90 | + | |
| 91 | + verify(exactly = 1) { scheduler.addJob(any<JobDetail>(), true, true) } | |
| 92 | + verify(exactly = 1) { scheduler.rescheduleJob(any<TriggerKey>(), any<Trigger>()) } | |
| 93 | + verify(exactly = 0) { scheduler.scheduleJob(any<Trigger>()) } | |
| 94 | + } | |
| 95 | + | |
| 96 | + @Test | |
| 97 | + fun `scheduleOnce uses a simple trigger at the requested instant`() { | |
| 98 | + every { scheduler.checkExists(any<TriggerKey>()) } returns false | |
| 99 | + | |
| 100 | + subject.scheduleOnce( | |
| 101 | + scheduleKey = "delayed", | |
| 102 | + handlerKey = "core.test.ping", | |
| 103 | + runAt = Instant.parse("2026-05-01T00:00:00Z"), | |
| 104 | + ) | |
| 105 | + | |
| 106 | + verify(exactly = 1) { scheduler.addJob(any<JobDetail>(), true, true) } | |
| 107 | + verify(exactly = 1) { scheduler.scheduleJob(any<Trigger>()) } | |
| 108 | + } | |
| 109 | + | |
| 110 | + @Test | |
| 111 | + fun `unschedule returns true when the job existed, false otherwise`() { | |
| 112 | + every { scheduler.checkExists(JobKey.jobKey("foo", QuartzJobScheduler.JOB_GROUP)) } returns true | |
| 113 | + every { scheduler.deleteJob(any<JobKey>()) } returns true | |
| 114 | + assertThat(subject.unschedule("foo")).isTrue() | |
| 115 | + | |
| 116 | + every { scheduler.checkExists(JobKey.jobKey("bar", QuartzJobScheduler.JOB_GROUP)) } returns false | |
| 117 | + assertThat(subject.unschedule("bar")).isFalse() | |
| 118 | + } | |
| 119 | + | |
| 120 | + @Test | |
| 121 | + fun `triggerNow calls the handler synchronously and returns ok=true on success`() { | |
| 122 | + val handler = FakeHandler("core.test.ping.sync") | |
| 123 | + val registry2 = JobHandlerRegistry(listOf(handler)) | |
| 124 | + val subj = QuartzJobScheduler(scheduler, registry2) | |
| 125 | + | |
| 126 | + val summary = subj.triggerNow("core.test.ping.sync", mapOf("k" to "v")) | |
| 127 | + | |
| 128 | + assertThat(handler.executed).isEqualTo(1) | |
| 129 | + assertThat(summary.handlerKey).isEqualTo("core.test.ping.sync") | |
| 130 | + assertThat(summary.ok).isTrue() | |
| 131 | + } | |
| 132 | + | |
| 133 | + @Test | |
| 134 | + fun `triggerNow propagates the handler's exception back to the caller`() { | |
| 135 | + class BoomHandler : JobHandler { | |
| 136 | + override fun key() = "core.test.boom" | |
| 137 | + override fun execute(context: JobContext) { | |
| 138 | + throw IllegalStateException("kaboom") | |
| 139 | + } | |
| 140 | + } | |
| 141 | + val subj = QuartzJobScheduler(scheduler, JobHandlerRegistry(listOf(BoomHandler()))) | |
| 142 | + | |
| 143 | + assertFailure { subj.triggerNow("core.test.boom") } | |
| 144 | + .isInstanceOf(IllegalStateException::class) | |
| 145 | + } | |
| 146 | + | |
| 147 | + @Test | |
| 148 | + fun `triggerNow rejects an unknown handler key`() { | |
| 149 | + assertFailure { subject.triggerNow("nope") } | |
| 150 | + .isInstanceOf(IllegalArgumentException::class) | |
| 151 | + } | |
| 152 | +} | ... | ... |
settings.gradle.kts
| ... | ... | @@ -45,6 +45,9 @@ project(":platform:platform-i18n").projectDir = file("platform/platform-i18n") |
| 45 | 45 | include(":platform:platform-workflow") |
| 46 | 46 | project(":platform:platform-workflow").projectDir = file("platform/platform-workflow") |
| 47 | 47 | |
| 48 | +include(":platform:platform-jobs") | |
| 49 | +project(":platform:platform-jobs").projectDir = file("platform/platform-jobs") | |
| 50 | + | |
| 48 | 51 | // ─── Packaged Business Capabilities (core PBCs) ───────────────────── |
| 49 | 52 | include(":pbc:pbc-identity") |
| 50 | 53 | project(":pbc:pbc-identity").projectDir = file("pbc/pbc-identity") | ... | ... |