Skip to content

fix(bpm): hot-reload flowable:class client-Java delegates without a restart#6294

Merged
iliyan-velichkov merged 1 commit into
masterfrom
fix/flowable-class-delegate-hot-reload
Jul 15, 2026
Merged

fix(bpm): hot-reload flowable:class client-Java delegates without a restart#6294
iliyan-velichkov merged 1 commit into
masterfrom
fix/flowable-class-delegate-hot-reload

Conversation

@iliyan-velichkov

Copy link
Copy Markdown
Contributor

Problem

A client-Java class used as a Flowable service-task delegate via flowable:class="my.fqn.Delegate" keeps running its previously compiled version after a republish — the change only takes effect after a server restart. This is hit by the intent delegate service task (e.g. custom.sales_invoices.DocumentNumberGeneratorDelegate in dirigiblelabs/sample-intent-multi-model, the Custom Java in the Web IDE blog sample): editing the delegate and re-publishing left the old bytecode running when a new sales invoice was created.

Root cause

Republish does compile a fresh ClientClassLoader and swap it into ClientClassLoaderHolder. But the flowable:class path never sees it:

  • Flowable's CommandContextInterceptor captures the engine classloader once at boot and copies it into every command context, so mutating the config's classloader at runtime does not reach delegate resolution.
  • ReflectUtil resolves the delegate with Class.forName(name, true, loader) (default useClassForNameClassLoading = true); the JVM caches the resolved Class against that fixed loader instance, so subsequent resolutions never re-consult it.
  • ClassDelegate additionally caches the instantiated delegate (activityBehaviorInstance) on the parsed service task, which lives in the process-definition cache; an unchanged .bpmn is not redeployed, so the old-version instance persists.

(The ${JavaTask} delegate-expression path was already hot-reload-safe — it resolves through the holder on every execution — but it cannot inject flowable:fields, which is why parameterized delegates use flowable:class.)

Fix

Make the fixed, boot-captured loader resolve against the current compiled generation on every call, and drop the cached delegate instance on rebuild:

  • ClientClassLoaderHolder (core-java) — add a swap-listener hook fired on every rebuild, so another module can react without depending on engine-java.
  • BpmFlowableConfigsetUseClassForNameClassLoading(false) so Flowable resolves delegates via ClassLoader.loadClass (overridable) instead of Class.forName (JVM-cached against the loader instance).
  • ClientAwareClassLoader — override loadClass to pass through to holder.current(), turning the fixed instance into a transparent view of the latest generation.
  • FlowableClientClassLoaderRefresher (new) — evict Flowable's process-definition cache on every client rebuild, so a fresh delegate instance of the recompiled class is created on the next process start.

The ${JavaTask} and ${JSTask} paths are unchanged.

Testing

  • New JavaBpmnIT.pure_class_delegate_reflects_a_recompiled_version_without_restart — deploys a v1 flowable:class delegate, runs the process, overwrites the same .java with v2 (.bpmn untouched), re-runs, and asserts the second run observes v2. Fails before the fix (stale v1), passes after.
  • Existing JavaBpmnIT.start_process_executes_both_java_delegate_styles and BpmnModelApiIT pass (both delegate styles + BPMN model APIs unaffected by the global useClassForNameClassLoading flag).
  • IntentEngineIT (intent delegateflowable:class path) executes with no assertion failures locally; note it cannot complete its report on macOS due to the pre-existing PollingWatchService teardown deadlock (LocalRegistryWatcher.destroy), unrelated to this change — it validates in CI (Linux).

🤖 Generated with Claude Code

…estart

A client-Java class used as a Flowable service-task delegate via
flowable:class="my.fqn.Delegate" (e.g. the intent `delegate` step) kept
running its previously compiled version after a republish, until the server
was restarted. Republish does compile a fresh ClientClassLoader, but Flowable
resolved the delegate with Class.forName(name, true, loader) against the
ClientAwareClassLoader instance it captured once at engine boot (via
CommandContextInterceptor), and the JVM caches the resolved class against that
fixed instance; it also caches the instantiated delegate on the parsed service
task in the process-definition cache.

Make the fixed, boot-captured loader resolve against the current compiled
generation on every call, and drop the cached delegate instance on rebuild:

- ClientClassLoaderHolder: add a swap-listener hook fired on every rebuild so
  another module can react without depending on engine-java.
- BpmFlowableConfig: setUseClassForNameClassLoading(false) so Flowable resolves
  delegates via ClassLoader.loadClass (overridable) instead of Class.forName
  (JVM-cached against the loader instance).
- ClientAwareClassLoader: override loadClass to pass through to
  holder.current(), turning the fixed instance into a transparent view of the
  latest generation.
- FlowableClientClassLoaderRefresher: evict the process-definition cache on
  every client rebuild so a fresh delegate instance of the recompiled class is
  created on the next process start.

The ${JavaTask} delegate-expression path was already hot-reload-safe and is
unchanged. Adds JavaBpmnIT.pure_class_delegate_reflects_a_recompiled_version_without_restart
(v1 -> recompile -> v2, .bpmn untouched) reproducing the bug and locking in the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@iliyan-velichkov

Copy link
Copy Markdown
Contributor Author

Tested locally successfully.

@iliyan-velichkov
iliyan-velichkov merged commit d88749e into master Jul 15, 2026
10 checks passed
@iliyan-velichkov
iliyan-velichkov deleted the fix/flowable-class-delegate-hot-reload branch July 15, 2026 06:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant