Replace serialization-based type inference with reified varargs type token#1524
Replace serialization-based type inference with reified varargs type token#1524mdproctor wants to merge 5 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR removes the previous serialization/SerializedLambda-based runtime type inference utilities and migrates the fluent function DSL to a “reified varargs type token” approach for recovering the raw input Class<T> at call sites, eliminating the need for Serializable* functional interfaces and ReflectionUtils.
Changes:
- Deleted
ReflectionUtilsand theSerializableFunction/SerializablePredicate/SerializableConsumerhelper interfaces; removedSerializablefromInstanceIdFunctionandUniqueIdBiFunction. - Updated fluent DSL entrypoints (
FuncDSL,Step,CommonFuncOps, and related specs/builders) to accept standard JDK functional interfaces plus an optional varargs type token for input class inference. - Added a test intended to demonstrate nested generic lambda type safety.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| experimental/types/src/main/java/io/serverlessworkflow/api/reflection/func/UniqueIdBiFunction.java | Drops Serializable from the functional interface now that serialization inference is removed. |
| experimental/types/src/main/java/io/serverlessworkflow/api/reflection/func/SerializablePredicate.java | Removed obsolete serializable predicate shim. |
| experimental/types/src/main/java/io/serverlessworkflow/api/reflection/func/SerializableFunction.java | Removed obsolete serializable function shim. |
| experimental/types/src/main/java/io/serverlessworkflow/api/reflection/func/SerializableConsumer.java | Removed obsolete serializable consumer shim. |
| experimental/types/src/main/java/io/serverlessworkflow/api/reflection/func/ReflectionUtils.java | Deleted serialization/reflection-based lambda type inference utility. |
| experimental/types/src/main/java/io/serverlessworkflow/api/reflection/func/InstanceIdFunction.java | Drops Serializable from the functional interface now that serialization inference is removed. |
| experimental/fluent/func/src/test/java/io/serverlessworkflow/fluent/func/FuncDSLTest.java | Adds a nested-generics-focused test for the updated DSL typing behavior. |
| experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/FuncEmitEventPropertiesBuilder.java | Accepts Function directly instead of a serializable wrapper. |
| experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/Step.java | Replaces reflection-based inference with varargs type token input-class derivation for chained operations. |
| experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/FuncEventFilterSpec.java | Switches envelope/data filter overloads from serializable predicates to standard Predicate. |
| experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/FuncEmitSpec.java | Adds varargs type-token overload for JSON event data emission. |
| experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/FuncDSL.java | Updates core DSL helpers to use varargs type tokens for input class inference and removes reflection-based return-type inference wiring. |
| experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/CommonFuncOps.java | Updates shared DSL ops to accept standard Function/Predicate with type-token inference. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| List<TaskItem> items = wf.getDo(); | ||
| assertEquals(1, items.size()); | ||
| assertNotNull(items.get(0).getTask().getCallTask(), "CallTask expected"); | ||
| } |
| @SuppressWarnings("unchecked") | ||
| private static <R> Class<R> defaultReturnClass(Class<R> c) { | ||
| return c != null ? c : (Class<R>) Object.class; | ||
| } |
| List<TaskItem> items = wf.getDo(); | ||
| assertEquals(1, items.size()); | ||
| assertNotNull(items.get(0).getTask().getCallTask(), "CallTask expected"); | ||
| } |
| @SuppressWarnings("unchecked") | ||
| private static <R> Class<R> defaultReturnClass(Class<R> c) { | ||
| return c != null ? c : (Class<R>) Object.class; | ||
| } |
| this.filterFn = null; | ||
| this.argClass = argClass; | ||
| this.returnClass = returnClass; | ||
| this.returnClass = defaultReturnClass(returnClass); |
| this.filterFn = null; | ||
| this.argClass = argClass; | ||
| this.returnClass = returnClass; | ||
| this.returnClass = defaultReturnClass(returnClass); |
| this.filterFn = filterFn; | ||
| this.argClass = argClass; | ||
| this.returnClass = returnClass; | ||
| this.returnClass = defaultReturnClass(returnClass); |
…token The fluent DSL previously used SerializableFunction/Predicate/Consumer interfaces and ReflectionUtils.inferInputType() to recover generic type information at runtime. This relied on JVM serialization internals (writeReplace → SerializedLambda) which are fragile, heavyweight, and force all lambdas to implement Serializable. Replace with the reified varargs type token pattern: declaring a trailing T... parameter causes Java to create a reified array at the call site, making the erased-but-concrete class recoverable via getClass().getComponentType(). This gives the same runtime Class<T> with zero reflection, zero serialization, zero allocation, and works with any lambda or method reference regardless of serializability. Changes: - Replace ~40 call sites across FuncDSL, Step, CommonFuncOps, FuncEmitSpec, FuncEmitEventPropertiesBuilder, and FuncEventFilterSpec - Delete ReflectionUtils, SerializableFunction, SerializablePredicate, SerializableConsumer (no longer needed) - Drop Serializable from InstanceIdFunction and UniqueIdBiFunction (was only needed for the inference hack; ContextFunction and FilterFunction keep it as they are stored in serializable CallJava objects) - Add test demonstrating nested generic type safety (Map<String, List<Integer>>) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Mark Proctor <mdproctor@gmail.com>
AbstractJavaCallExecutor uses outputClass.isPresent() to decide whether to run functions via CompletableFuture.supplyAsync (async) or completedFuture (sync). Passing null for the return class caused functions to run synchronously, breaking the async contract that FuncCallAsyncTest.testReferencedFunctionCall relies on. Default null returnClass to Object.class in FuncCallStep constructors so the executor always takes the async path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Mark Proctor <mdproctor@gmail.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Mark Proctor <mdproctor@gmail.com>
a64f8bc to
2e76c0c
Compare
The executor's else branch (null outputClass) ran the function synchronously on the calling thread, unlike the outputClass.isPresent() branch which used supplyAsync. This caused timing-sensitive tests to fail when the varargs refactoring stopped providing an output class. Change the else branch to also use supplyAsync, then thenCompose with the runtime instanceof CompletableFuture check. This correctly handles both sync and async function return types while always executing on the background thread pool. Revert the Object.class default in FuncCallStep — null outputClass is now handled correctly by the executor. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Mark Proctor <mdproctor@gmail.com>
| List<TaskItem> items = wf.getDo(); | ||
| assertEquals(1, items.size()); | ||
| assertNotNull(items.get(0).getTask().getCallTask(), "CallTask expected"); | ||
| } |
| @SuppressWarnings("unchecked") | ||
| default <T, V> Consumer<FuncCallTaskBuilder> fn(Function<T, V> function, T... typeToken) { | ||
| Class<T> clazz = (Class<T>) typeToken.getClass().getComponentType(); | ||
| return fn(function, clazz); | ||
| } |
| @SuppressWarnings("unchecked") | ||
| default <T> SwitchCaseSpec<T> caseOf(Predicate<T> when, T... typeToken) { | ||
| return caseOf(when, (Class<T>) typeToken.getClass().getComponentType()); | ||
| } |
|
We need serializable functions, consumer and predicates for YAML/JSON serialization, so the serialization classes cannot be removed. They must be kept for Json/yaml serialization regardless of the inference mechanism used. For type inference, the PR proposal is smarter and faster than the current inference method based on SerializedLambda. But there is a caveat, it does not resolve the output class, which is internally used during execution time to convert the output. I'm not sure the solution taken in AbstractJavaCallExecutor to circumvent that is correct ( I guess the CI will fail). And even if it does not fail, the consequence of not knowing in advance that the function is returning a completable future is that we were creating an additional async request unnecessarily, which is probably heavier (I elaborate more in the last paragraph) than accessing the serializedlamda to check if the function is returning a CompletableFuture. That's said, I really liked the approach for input parameter taken here, and since we need SerializedLambda for YAML/Json serialization, I'm thinking on an hybrid approach. I was already contemplating some extra improvements on ReflectionUtils (calling inferMethodType only once and cache the resolution of Method Type) which, together with the component type approach might dramatically improve type inference performance without losing the completablefuture check at runtime. An important remark about inference performance, the type inference only occurs during workflow definition time (typically at startup), so the performance improvement does not really affect execution time, but startup time, since the definitions are loaded at startup, hence we do not invest much effort on improving type inference performance (in other words, type inference is a one time operation, that is the reason why the CompletablueFuture change is important, it affects all workflow executions, while output class inference is just performed once during workflow definition, so it can be argued that the current change in AbstractJavaCallExceutor will make performance worse when invoking java methods that are already async, as paradoxically as it might sound) |
…ssertion - Add "varargs" to @SuppressWarnings on CommonFuncOps default methods - Strengthen nested generics test to verify inferred inputClass is Map.class Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Mark Proctor <mdproctor@gmail.com>
| public static <T> EmitStep emit(String type, Function<T, CloudEventData> fn) { | ||
| return new EmitStep(null, produced(type, fn)); | ||
| } |
| public static <T> EmitStep emit(String name, String type, Function<T, CloudEventData> fn) { | ||
| return new EmitStep(name, produced(type, fn)); | ||
| } |
|
PR for reference only, thanks Mark! |
Summary
SerializableFunction/SerializablePredicate/SerializableConsumer+ReflectionUtils.inferInputType()with the reified varargs type token pattern across ~40 call sitesReflectionUtils,SerializableFunction,SerializablePredicate,SerializableConsumer— no longer neededSerializablefromInstanceIdFunctionandUniqueIdBiFunction(was only needed for the inference hack)Map<String, List<Integer>>)Motivation
The previous approach used JVM serialization internals (
writeReplace→SerializedLambda→getInstantiatedMethodType) to recover erased type parameters at runtime. This is:SerializableThe reified varargs type token pattern achieves the same result (recovering the raw
Class<T>at runtime) with zero reflection, zero serialization, and works with any lambda or method reference regardless of serializability. Java reifies array creation at the call site even for generic varargs parameters, soT... typeTokenwith zero arguments creates anew T[0]whose component type is recoverable viagetClass().getComponentType().Test plan
function_with_nested_generics_preserves_type_safety— verifiesMap<String, List<Integer>>flows through with compile-time type safety🤖 Generated with Claude Code