• 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.
    zichun authored
     
    Browse Code »

  • BLOCKER: wire Hibernate multi-tenancy
    - application.yaml: set hibernate.tenant_identifier_resolver and
      hibernate.multiTenancy=DISCRIMINATOR so HibernateTenantResolver is
      actually installed into the SessionFactory
    - AuditedJpaEntity.tenantId: add @org.hibernate.annotations.TenantId so
      every PBC entity inherits the discriminator
    - AuditedJpaEntityListener.onCreate: throw if a caller pre-set tenantId
      to a different value than the current TenantContext, instead of
      silently overwriting (defense against cross-tenant write bugs)
    
    IMPORTANT: dependency hygiene
    - pbc-identity no longer depends on platform-bootstrap (wrong direction;
      bootstrap assembles PBCs at the top of the stack)
    - root build.gradle.kts: tighten the architectural-rule enforcement to
      also reject :pbc:* -> platform-bootstrap; switch plug-in detection
      from a fragile pathname heuristic to an explicit
      extra["vibeerp.module-kind"] = "plugin" marker; reference plug-in
      declares the marker
    
    IMPORTANT: api.v1 surface additions (all non-breaking)
    - Repository: documented closed exception set; new
      PersistenceExceptions.kt declares OptimisticLockConflictException,
      UniqueConstraintViolationException, EntityValidationException, and
      EntityNotFoundException so plug-ins never see Hibernate types
    - TaskContext: now exposes tenantId(), principal(), locale(),
      correlationId() so workflow handlers (which run outside an HTTP
      request) can pass tenant-aware calls back into api.v1
    - EventBus: subscribe() now returns a Subscription with close() so
      long-lived subscribers can deregister explicitly; added a
      subscribe(topic: String, ...) overload for cross-classloader event
      routing where Class<E> equality is unreliable
    - IdentityApi.findUserById: tightened from Id<*> to PrincipalId so the
      type system rejects "wrong-id-kind" mistakes at the cross-PBC boundary
    
    NITs:
    - HealthController.kt -> MetaController.kt (file name now matches the
      class name); added TODO(v0.2) for reading implementationVersion from
      the Spring Boot BuildProperties bean
    vibe_erp authored
     
    Browse Code »
  • vibe_erp authored
     
    Browse Code »