fix(bpm): hot-reload flowable:class client-Java delegates without a restart#6294
Merged
Merged
Conversation
…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>
Contributor
Author
|
Tested locally successfully. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 intentdelegateservice task (e.g.custom.sales_invoices.DocumentNumberGeneratorDelegateindirigiblelabs/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
ClientClassLoaderand swap it intoClientClassLoaderHolder. But theflowable:classpath never sees it:CommandContextInterceptorcaptures 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.ReflectUtilresolves the delegate withClass.forName(name, true, loader)(defaultuseClassForNameClassLoading = true); the JVM caches the resolvedClassagainst that fixed loader instance, so subsequent resolutions never re-consult it.ClassDelegateadditionally caches the instantiated delegate (activityBehaviorInstance) on the parsed service task, which lives in the process-definition cache; an unchanged.bpmnis 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 injectflowable:fields, which is why parameterized delegates useflowable: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 onengine-java.BpmFlowableConfig—setUseClassForNameClassLoading(false)so Flowable resolves delegates viaClassLoader.loadClass(overridable) instead ofClass.forName(JVM-cached against the loader instance).ClientAwareClassLoader— overrideloadClassto pass through toholder.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
JavaBpmnIT.pure_class_delegate_reflects_a_recompiled_version_without_restart— deploys a v1flowable:classdelegate, runs the process, overwrites the same.javawith v2 (.bpmnuntouched), re-runs, and asserts the second run observes v2. Fails before the fix (stale v1), passes after.JavaBpmnIT.start_process_executes_both_java_delegate_stylesandBpmnModelApiITpass (both delegate styles + BPMN model APIs unaffected by the globaluseClassForNameClassLoadingflag).IntentEngineIT(intentdelegate→flowable:classpath) executes with no assertion failures locally; note it cannot complete its report on macOS due to the pre-existingPollingWatchServiceteardown deadlock (LocalRegistryWatcher.destroy), unrelated to this change — it validates in CI (Linux).🤖 Generated with Claude Code