Commit 1a45a4b7275da3b9c739b198fd97de27d3a3431a

Authored by zichun
1 parent d04eb7f4

feat(jobs+plugins): plug-in loader JobHandler registration + reference PlateCleanupJobHandler

P1.10 follow-up. Plug-ins can now register background job handlers
the same way they already register workflow task handlers. The
reference printing-shop plug-in ships a real PlateCleanupJobHandler
that reads from its own database via `context.jdbc` as the
executable acceptance test.

## Why this wasn't in the P1.10 chunk

P1.10 landed the core scheduler + registry + Quartz bridge + HTTP
surface, but the plug-in-loader integration was deliberately
deferred — the JobHandlerRegistry already supported owner-tagged
`register(handler, ownerId)` and `unregisterAllByOwner(ownerId)`,
so the seam was defined; it just didn't have a caller from the
PF4J plug-in side. Without a real plug-in consumer, shipping the
integration would have been speculative.

This commit closes the gap in exactly the shape the TaskHandler
side already has: new api.v1 registrar interface, new scoped
registrar in platform-plugins, one constructor parameter on
DefaultPluginContext, one new field on VibeErpPluginManager, and
the teardown paths all fall out automatically because
JobHandlerRegistry already implements the owner-tagged cleanup.

## api.v1 additions

- `org.vibeerp.api.v1.jobs.PluginJobHandlerRegistrar` — single
  method `register(handler: JobHandler)`. Mirrors
  `PluginTaskHandlerRegistrar` exactly, same ergonomics, same
  duplicate-key-throws discipline.
- `PluginContext.jobs: PluginJobHandlerRegistrar` — new optional
  member with the default-throw backward-compat pattern used for
  `endpoints`, `jdbc`, `taskHandlers`, and `files`. An older host
  loading a newer plug-in jar fails loudly at first call rather
  than silently dropping scheduled work.

## platform-plugins wiring

- New dependency on `:platform:platform-jobs`.
- New internal class
  `org.vibeerp.platform.plugins.jobs.ScopedJobHandlerRegistrar`
  that implements the api.v1 registrar by delegating
  `register(handler)` to `hostRegistry.register(handler, ownerId = pluginId)`.
- `DefaultPluginContext` gains a `scopedJobHandlers` constructor
  parameter and exposes it as `PluginContext.jobs`.
- `VibeErpPluginManager`:
  * injects `JobHandlerRegistry`
  * constructs `ScopedJobHandlerRegistrar(registry, pluginId)` per
    plug-in when building `DefaultPluginContext`
  * partial-start failure now also calls
    `jobHandlerRegistry.unregisterAllByOwner(pluginId)`, matching
    the existing endpoint + taskHandler + BPMN-deployment cleanups
  * `destroy()` reverse-iterates `started` and calls the same
    `unregisterAllByOwner` alongside the other four teardown steps

## Reference plug-in — PlateCleanupJobHandler

New file
`reference-customer/plugin-printing-shop/.../jobs/PlateCleanupJobHandler.kt`.
Key `printing_shop.plate.cleanup`. Captures the `PluginContext`
via constructor — same "handler-side plug-in context access"
pattern the printing-shop plug-in already uses for its
TaskHandlers.

The handler is READ-ONLY in its v1 incarnation: it runs a
GROUP-BY query over `plugin_printingshop__plate` via
`context.jdbc.query(...)` and logs a per-status summary via
`context.logger.info(...)`. A real cleanup job would also run an
`UPDATE`/`DELETE` to prune DRAFT plates older than N days; the
read-only shape is enough to exercise the seam end-to-end without
introducing a retention policy the customer hasn't asked for.

`PrintingShopPlugin.start(context)` now registers the handler
alongside its two TaskHandlers:

    context.taskHandlers.register(PlateApprovalTaskHandler(context))
    context.taskHandlers.register(CreateWorkOrderFromQuoteTaskHandler(context))
    context.jobs.register(PlateCleanupJobHandler(context))

## Smoke test (fresh DB, plug-in staged)

```
# boot
registered JobHandler 'vibeerp.jobs.ping' owner='core' ...
JobHandlerRegistry initialised with 1 core JobHandler bean(s): [vibeerp.jobs.ping]
...
registered JobHandler 'printing_shop.plate.cleanup' owner='printing-shop' ...
[plugin:printing-shop] registered 1 JobHandler: printing_shop.plate.cleanup

# HTTP: list handlers — now shows both
GET /api/v1/jobs/handlers
  → {"count":2,"keys":["printing_shop.plate.cleanup","vibeerp.jobs.ping"]}

# HTTP: trigger the plug-in handler — proves dispatcher routes to it
POST /api/v1/jobs/handlers/printing_shop.plate.cleanup/trigger
  → 200 {"handlerKey":"printing_shop.plate.cleanup",
         "correlationId":"95969129-d6bf-4d9a-8359-88310c4f63b9",
         "startedAt":"...","finishedAt":"...","ok":true}

# Handler-side logs prove context.jdbc + context.logger access
[plugin:printing-shop] PlateCleanupJobHandler firing corr='95969129-...'
[plugin:printing-shop] PlateCleanupJobHandler summary: total=0 byStatus=[]

# SIGTERM — clean teardown
[ionShutdownHook] TaskHandlerRegistry.unregisterAllByOwner('printing-shop') removed 2 handler(s)
[ionShutdownHook] unregistered JobHandler 'printing_shop.plate.cleanup' (owner stopped)
[ionShutdownHook] JobHandlerRegistry.unregisterAllByOwner('printing-shop') removed 1 handler(s)
```

Every expected lifecycle event fires in the right order. Core
handlers are untouched by plug-in teardown.

## Tests

No new unit tests in this commit — the test coverage is inherited
from the previously landed components:
  - `JobHandlerRegistryTest` already covers owner-tagged
    `register` / `unregister` / `unregisterAllByOwner` / duplicate
    key rejection.
  - `ScopedTaskHandlerRegistrar` behavior (which this commit
    mirrors structurally) is exercised end-to-end by the
    printing-shop plug-in boot path.
- Total framework unit tests: 334 (unchanged from the
  quality→warehousing quarantine chunk), all green.

## What this unblocks

- **Plug-in-shipped scheduled work.** The printing-shop plug-in
  can now add cron schedules for its cleanup handler via
  `POST /api/v1/jobs/scheduled {scheduleKey, handlerKey,
  cronExpression}` without the operator touching core code.
- **Plug-in-to-plug-in handler coexistence.** Two plug-ins can
  now ship job handlers with distinct keys and be torn down
  independently on reload — the owner-tagged cleanup strips only
  the stopping plug-in's handlers, leaving other plug-ins' and
  core handlers alone.
- **The "plug-in contributes everything" story.** The reference
  printing-shop plug-in now contributes via every public seam the
  framework has: HTTP endpoints (7), custom fields on core
  entities (5), BPMNs (2), TaskHandlers (2), and a JobHandler (1)
  — plus its own database schema, its own metadata YAML, its own
  i18n bundles. That's every extension point a real customer
  plug-in would want.

## Non-goals (parking lot)

- A real retention policy in PlateCleanupJobHandler. The handler
  logs a summary but doesn't mutate state. Customer-specific
  pruning rules belong in a customer-owned plug-in or a metadata-
  driven rule once that seam exists.
- A built-in cron schedule for the plug-in's handler. The
  plug-in only registers the handler; scheduling is an operator
  decision exposed through the HTTP surface from P1.10.
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/jobs/PluginJobHandlerRegistrar.kt 0 → 100644
  1 +package org.vibeerp.api.v1.jobs
  2 +
  3 +/**
  4 + * The per-plug-in registrar a plug-in uses to register its own
  5 + * [JobHandler] implementations with the framework's Quartz-backed
  6 + * scheduler.
  7 + *
  8 + * Wired at plug-in start time via
  9 + * [org.vibeerp.api.v1.plugin.PluginContext.jobs]. A plug-in calls it
  10 + * from inside its `start(context)` lambda:
  11 + *
  12 + * ```
  13 + * class PrintingShopPlugin(wrapper: PluginWrapper) : Plugin(wrapper) {
  14 + * override fun start(context: PluginContext) {
  15 + * context.jobs.register(PlateCleanupJobHandler(context))
  16 + * context.jobs.register(QuoteExpiryJobHandler(context))
  17 + * }
  18 + * }
  19 + * ```
  20 + *
  21 + * Exactly mirrors the [org.vibeerp.api.v1.workflow.PluginTaskHandlerRegistrar]
  22 + * seam landed with P2.1's plug-in-loaded TaskHandler support:
  23 + * - Ownership is invisible to the plug-in author. The host records
  24 + * each call against the plug-in's id so the framework can
  25 + * `unregisterAll` at stop time without the plug-in tracking the
  26 + * keys it registered.
  27 + * - Plug-ins cannot impersonate another plug-in's namespace. The
  28 + * registrar is constructed by the host with a fixed plug-in id.
  29 + * - The handler may hold a reference to the PluginContext captured
  30 + * at registration time (same "handler-side context access"
  31 + * pattern the framework's workflow handlers use) so it can write
  32 + * to the plug-in's own tables via `context.jdbc`, log through
  33 + * `context.logger`, and publish events via `context.eventBus`.
  34 + *
  35 + * Duplicate-key rejection: if a plug-in tries to register a
  36 + * [JobHandler.key] that a core handler or another plug-in already
  37 + * owns, [register] throws `IllegalStateException` and the plug-in's
  38 + * `start(context)` fails. The plug-in loader strips any partial
  39 + * registrations the plug-in made before throwing so a half-
  40 + * registered state cannot linger.
  41 + */
  42 +public interface PluginJobHandlerRegistrar {
  43 +
  44 + /**
  45 + * Register a [JobHandler] with the framework so manual
  46 + * `POST /api/v1/jobs/handlers/{key}/trigger` calls and scheduled
  47 + * cron/one-shot jobs targeting [JobHandler.key] are dispatched
  48 + * to it.
  49 + */
  50 + public fun register(handler: JobHandler)
  51 +}
... ...
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginContext.kt
... ... @@ -5,6 +5,7 @@ import org.vibeerp.api.v1.event.EventBus
5 5 import org.vibeerp.api.v1.i18n.LocaleProvider
6 6 import org.vibeerp.api.v1.i18n.Translator
7 7 import org.vibeerp.api.v1.files.FileStorage
  8 +import org.vibeerp.api.v1.jobs.PluginJobHandlerRegistrar
8 9 import org.vibeerp.api.v1.persistence.Transaction
9 10 import org.vibeerp.api.v1.security.PermissionCheck
10 11 import org.vibeerp.api.v1.workflow.PluginTaskHandlerRegistrar
... ... @@ -129,6 +130,24 @@ interface PluginContext {
129 130 "PluginContext.files is not implemented by this host. " +
130 131 "Upgrade vibe_erp to v0.8 or later."
131 132 )
  133 +
  134 + /**
  135 + * Register [org.vibeerp.api.v1.jobs.JobHandler] implementations
  136 + * with the framework's Quartz-backed scheduler. Plug-ins that
  137 + * ship recurring or one-shot background work supply a handler
  138 + * per job key through this registrar.
  139 + *
  140 + * Added in api.v1.0.x alongside P1.10 (platform-jobs). Default
  141 + * implementation throws so an older host running a newer plug-in
  142 + * jar fails loudly at first call rather than silently dropping
  143 + * scheduled work. Backward-compat pattern matches `endpoints`,
  144 + * `jdbc`, `taskHandlers`, and `files`.
  145 + */
  146 + val jobs: PluginJobHandlerRegistrar
  147 + get() = throw UnsupportedOperationException(
  148 + "PluginContext.jobs is not implemented by this host. " +
  149 + "Upgrade vibe_erp to v0.9 or later."
  150 + )
132 151 }
133 152  
134 153 /**
... ...
platform/platform-plugins/build.gradle.kts
... ... @@ -29,6 +29,7 @@ dependencies {
29 29 implementation(project(":platform:platform-i18n")) // for per-plug-in IcuTranslator + shared LocaleProvider
30 30 implementation(project(":platform:platform-security")) // for PermissionEvaluator refresh wiring
31 31 implementation(project(":platform:platform-workflow")) // for per-plug-in TaskHandler registration
  32 + implementation(project(":platform:platform-jobs")) // for per-plug-in JobHandler registration
32 33  
33 34 implementation(libs.spring.boot.starter)
34 35 implementation(libs.spring.boot.starter.web) // for @RestController on the dispatcher
... ...
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/DefaultPluginContext.kt
... ... @@ -9,6 +9,7 @@ import org.vibeerp.api.v1.persistence.Transaction
9 9 import org.vibeerp.api.v1.plugin.PluginContext
10 10 import org.vibeerp.api.v1.plugin.PluginEndpointRegistrar
11 11 import org.vibeerp.api.v1.plugin.PluginJdbc
  12 +import org.vibeerp.api.v1.jobs.PluginJobHandlerRegistrar
12 13 import org.vibeerp.api.v1.plugin.PluginLogger
13 14 import org.vibeerp.api.v1.security.PermissionCheck
14 15 import org.vibeerp.api.v1.workflow.PluginTaskHandlerRegistrar
... ... @@ -49,6 +50,7 @@ internal class DefaultPluginContext(
49 50 private val pluginTranslator: Translator,
50 51 private val sharedLocaleProvider: LocaleProvider,
51 52 private val scopedTaskHandlers: PluginTaskHandlerRegistrar,
  53 + private val scopedJobHandlers: PluginJobHandlerRegistrar,
52 54 ) : PluginContext {
53 55  
54 56 override val logger: PluginLogger = Slf4jPluginLogger(pluginId, delegateLogger)
... ... @@ -107,6 +109,16 @@ internal class DefaultPluginContext(
107 109 */
108 110 override val taskHandlers: PluginTaskHandlerRegistrar = scopedTaskHandlers
109 111  
  112 + /**
  113 + * Per-plug-in [PluginJobHandlerRegistrar] wired in P1.10's
  114 + * follow-up. The scoped registrar tags every registration with
  115 + * this plug-in's id so the framework's [JobHandlerRegistry] can
  116 + * strip every handler the plug-in contributed with a single
  117 + * `unregisterAllByOwner(pluginId)` call at plug-in stop time.
  118 + * Same discipline as the workflow-side taskHandlers registrar.
  119 + */
  120 + override val jobs: PluginJobHandlerRegistrar = scopedJobHandlers
  121 +
110 122 // ─── Not yet implemented ───────────────────────────────────────
111 123  
112 124 override val transaction: Transaction
... ...
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt
... ... @@ -16,9 +16,11 @@ import org.vibeerp.platform.metadata.customfield.CustomFieldRegistry
16 16 import org.vibeerp.platform.security.authz.PermissionEvaluator
17 17 import org.vibeerp.platform.plugins.endpoints.PluginEndpointRegistry
18 18 import org.vibeerp.platform.plugins.endpoints.ScopedPluginEndpointRegistrar
  19 +import org.vibeerp.platform.plugins.jobs.ScopedJobHandlerRegistrar
19 20 import org.vibeerp.platform.plugins.lint.PluginLinter
20 21 import org.vibeerp.platform.plugins.migration.PluginLiquibaseRunner
21 22 import org.vibeerp.platform.plugins.workflow.ScopedTaskHandlerRegistrar
  23 +import org.vibeerp.platform.jobs.JobHandlerRegistry
22 24 import org.vibeerp.platform.workflow.PluginProcessDeployer
23 25 import org.vibeerp.platform.workflow.TaskHandlerRegistry
24 26 import java.nio.file.Files
... ... @@ -70,6 +72,7 @@ class VibeErpPluginManager(
70 72 private val localeProvider: LocaleProvider,
71 73 private val taskHandlerRegistry: TaskHandlerRegistry,
72 74 private val pluginProcessDeployer: PluginProcessDeployer,
  75 + private val jobHandlerRegistry: JobHandlerRegistry,
73 76 ) : DefaultPluginManager(Paths.get(properties.directory)), InitializingBean, DisposableBean {
74 77  
75 78 private val log = LoggerFactory.getLogger(VibeErpPluginManager::class.java)
... ... @@ -281,6 +284,7 @@ class VibeErpPluginManager(
281 284 pluginTranslator = pluginTranslator,
282 285 sharedLocaleProvider = localeProvider,
283 286 scopedTaskHandlers = ScopedTaskHandlerRegistrar(taskHandlerRegistry, pluginId),
  287 + scopedJobHandlers = ScopedJobHandlerRegistrar(jobHandlerRegistry, pluginId),
284 288 )
285 289 try {
286 290 vibeErpPlugin.start(context)
... ... @@ -296,6 +300,7 @@ class VibeErpPluginManager(
296 300 // undeployment is unnecessary here.
297 301 endpointRegistry.unregisterAll(pluginId)
298 302 taskHandlerRegistry.unregisterAllByOwner(pluginId)
  303 + jobHandlerRegistry.unregisterAllByOwner(pluginId)
299 304 return
300 305 }
301 306  
... ... @@ -326,6 +331,7 @@ class VibeErpPluginManager(
326 331 started.removeAll { it.first == pluginId }
327 332 endpointRegistry.unregisterAll(pluginId)
328 333 taskHandlerRegistry.unregisterAllByOwner(pluginId)
  334 + jobHandlerRegistry.unregisterAllByOwner(pluginId)
329 335 pluginProcessDeployer.undeployByPlugin(pluginId)
330 336 }
331 337 }
... ... @@ -341,6 +347,7 @@ class VibeErpPluginManager(
341 347 }
342 348 endpointRegistry.unregisterAll(pluginId)
343 349 taskHandlerRegistry.unregisterAllByOwner(pluginId)
  350 + jobHandlerRegistry.unregisterAllByOwner(pluginId)
344 351 pluginProcessDeployer.undeployByPlugin(pluginId)
345 352 }
346 353 started.clear()
... ...
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/jobs/ScopedJobHandlerRegistrar.kt 0 → 100644
  1 +package org.vibeerp.platform.plugins.jobs
  2 +
  3 +import org.vibeerp.api.v1.jobs.JobHandler
  4 +import org.vibeerp.api.v1.jobs.PluginJobHandlerRegistrar
  5 +import org.vibeerp.platform.jobs.JobHandlerRegistry
  6 +
  7 +/**
  8 + * The per-plug-in implementation of [PluginJobHandlerRegistrar] that
  9 + * plug-ins receive through
  10 + * [org.vibeerp.api.v1.plugin.PluginContext.jobs].
  11 + *
  12 + * Parallel to the workflow-side
  13 + * [org.vibeerp.platform.plugins.workflow.ScopedTaskHandlerRegistrar].
  14 + * Holds a reference to the framework's host [JobHandlerRegistry]
  15 + * and a fixed `pluginId`; every [register] call tags the
  16 + * registration with the plug-in's id so the host can drop every
  17 + * handler the plug-in contributed with a single
  18 + * `JobHandlerRegistry.unregisterAllByOwner(pluginId)` call at
  19 + * plug-in stop time.
  20 + *
  21 + * Plug-ins cannot impersonate another plug-in's namespace because
  22 + * they never set the `ownerId` argument — it's baked into this
  23 + * registrar by the host when it constructs
  24 + * [org.vibeerp.platform.plugins.DefaultPluginContext].
  25 + */
  26 +internal class ScopedJobHandlerRegistrar(
  27 + private val hostRegistry: JobHandlerRegistry,
  28 + private val pluginId: String,
  29 +) : PluginJobHandlerRegistrar {
  30 +
  31 + override fun register(handler: JobHandler) {
  32 + hostRegistry.register(handler, ownerId = pluginId)
  33 + }
  34 +}
... ...
reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/PrintingShopPlugin.kt
... ... @@ -6,6 +6,7 @@ import org.vibeerp.api.v1.plugin.HttpMethod
6 6 import org.vibeerp.api.v1.plugin.PluginContext
7 7 import org.vibeerp.api.v1.plugin.PluginRequest
8 8 import org.vibeerp.api.v1.plugin.PluginResponse
  9 +import org.vibeerp.reference.printingshop.jobs.PlateCleanupJobHandler
9 10 import org.vibeerp.reference.printingshop.workflow.CreateWorkOrderFromQuoteTaskHandler
10 11 import org.vibeerp.reference.printingshop.workflow.PlateApprovalTaskHandler
11 12 import java.time.Instant
... ... @@ -288,6 +289,12 @@ class PrintingShopPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpP
288 289 "registered 2 TaskHandlers: " +
289 290 "${PlateApprovalTaskHandler.KEY}, ${CreateWorkOrderFromQuoteTaskHandler.KEY}",
290 291 )
  292 +
  293 + // ─── Job handlers (P1.10 follow-up — plug-in loaded JobHandlers) ──
  294 + context.jobs.register(PlateCleanupJobHandler(context))
  295 + context.logger.info(
  296 + "registered 1 JobHandler: ${PlateCleanupJobHandler.KEY}",
  297 + )
291 298 }
292 299  
293 300 /**
... ...
reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/jobs/PlateCleanupJobHandler.kt 0 → 100644
  1 +package org.vibeerp.reference.printingshop.jobs
  2 +
  3 +import org.vibeerp.api.v1.jobs.JobContext
  4 +import org.vibeerp.api.v1.jobs.JobHandler
  5 +import org.vibeerp.api.v1.plugin.PluginContext
  6 +
  7 +/**
  8 + * Reference plug-in-contributed JobHandler — the executable
  9 + * acceptance test for the P1.10 follow-up "plug-in loaded
  10 + * JobHandler registration" seam.
  11 + *
  12 + * Captures the [PluginContext] via constructor so it can reach
  13 + * `context.jdbc` / `context.logger` from inside [execute], matching
  14 + * the same "handler-side plug-in context access" pattern the
  15 + * workflow-side `PlateApprovalTaskHandler` uses.
  16 + *
  17 + * **What it does.** Logs a summary of plate counts per status in
  18 + * the printing-shop schema, using `context.jdbc` to read from
  19 + * `plugin_printingshop__plate`. Zero mutation — this handler is a
  20 + * reporting-only job suitable for a nightly cron schedule. Real
  21 + * cleanup jobs (e.g. "delete DRAFT plates older than 90 days")
  22 + * would layer an `UPDATE` or `DELETE` on the same `context.jdbc`
  23 + * surface.
  24 + *
  25 + * **Key naming.** `printing_shop.plate.cleanup` — same
  26 + * underscore-in-segments convention as the workflow task keys so
  27 + * both plug-in-contributed seams look alike in the framework's
  28 + * log lines.
  29 + */
  30 +class PlateCleanupJobHandler(
  31 + private val context: PluginContext,
  32 +) : JobHandler {
  33 +
  34 + override fun key(): String = KEY
  35 +
  36 + override fun execute(jobContext: JobContext) {
  37 + context.logger.info(
  38 + "PlateCleanupJobHandler firing corr='${jobContext.correlationId()}'",
  39 + )
  40 +
  41 + val rows = context.jdbc.query(
  42 + "SELECT status, COUNT(*) AS c FROM plugin_printingshop__plate GROUP BY status",
  43 + ) { row ->
  44 + mapOf("status" to row.string("status"), "count" to row.int("c"))
  45 + }
  46 +
  47 + val total = rows.sumOf { (it["count"] as Int) }
  48 + context.logger.info(
  49 + "PlateCleanupJobHandler summary: total=$total byStatus=$rows",
  50 + )
  51 + }
  52 +
  53 + companion object {
  54 + const val KEY: String = "printing_shop.plate.cleanup"
  55 + }
  56 +}
... ...